fix: multidispositivo - Random seguro + gestor host + reacción clientes
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

- Random.secure() para selección de impostores (no predecible)
- Random.secure() también en desempate de votación
- Nueva PantallaGestorHost para coordinación multi-device
- Navegación: host va a gestor tras iniciar, no a pantalla de palabra
- PantallaPalabraCliente: cada jugador ve su palabra en su móvil
- PantallaDebateCliente: debate con timer y botón solicitar votación
- PantallaVotacionCliente: voto desde el móvil del cliente
- PantallaUnirse: listener que reacciona a partidaInicio y cambia de fase
- Protocolo: listo/voto/solicitoVotacion via Nearby hacia el host
- Nuevas cadenas l10n ES
This commit is contained in:
ShanaiaBot
2026-04-15 02:09:05 +02:00
parent 302cdf6f1a
commit eb2662f561
27 changed files with 2282 additions and 60 deletions

196
SPEC.md Normal file
View File

@@ -0,0 +1,196 @@
# Farolero — SDD: Multidispositivo completo + Impostor seguro
## 1. Explore
### Hallazgos
**Bug 1: El host funciona como juego en un solo móvil**
- En `pantalla_crear_partida.dart``_iniciarPartidaMulti()`, al pulsar "Iniciar":
1. Crea la partida localmente ✅
2. Envía palabras a clientes via Nearby ✅
3. Navega a `PantallaVerPalabra`
- `PantallaVerPalabra` está diseñada para juego en un solo móvil (muestra TODOS los jugadores en lista)
- El host no necesita ver palabras — es el gestor, no un jugador
**Bug 2: Clientes no se enteran de que el juego ha started**
- `ServicioNearby` procesa `partidaInicio` y actualiza `_palabraRecibida` / `_soyImpostor`
- Llama a `notifyListeners()`
- Pero `PantallaUnirse``_buildPantallaEspera` no escucha cambios de `ServicioNearby`
- Se queda infinamente en "Esperando al host..." aunque la partida ya haya started
**Bug 3: Random de impostores predecible**
- `EstadoJuego.crearPartida()` usa `Random()` con seed del sistema (determinista)
- `indices.shuffle(rng)` mezcla un array [0,1,2,...,n-1] — la mezcla depende del estado inicial
- Si un atacante conoce el número de jugadores y el momento de creación, puede reconstruir la seed
- Solución: usar `Random.secure()` (CSPRNG) o al menos `Random.fromSecureRandom()`
### Restricciones
- Nearby Connections tiene límite de payloads (4KB por mensaje)
- Google Nearby Connections requiere Bluetooth + Location permissions en Android/iOS
- La palabra secreta NO debe enviarse en texto plano a la red ( Nearby es inseguro en teoría — cualquier dispositivo puede sniffear)
- Compatibilidad hacia atrás: modo un solo móvil no debe romperse
---
## 2. Propose
### Cambio 1: Pantalla Gestor Host (multidispositivo)
El host en modo multi-device necesita una pantalla que:
- Muestre la lista de jugadores conectados y sus estados (listo para debate/votación)
- Permita avanzar de fase (debate → votación → resultado)
- NO muestre palabras a nadie
- Gestione la coordinación de la partida completa
**Pantallas afectadas/creadas:**
- Nueva: `pantalla_gestor_host.dart`
- Modificar: `pantalla_crear_partida.dart` (onIniciar → navegar a gestor)
- Modificar: `pantalla_lobby_host.dart` (el QR se queda ahí)
### Cambio 2: Reacción de clientes al inicio de partida
Los clientes deben:
- Escuchar cambios en `ServicioNearby` (registrar listener)
- Cuando llega `partidaInicio`, navegar automáticamente a la pantalla de ver su palabra
- Crear pantalla dedicada para cliente: `pantalla_palabra_cliente.dart`
**Pantallas afectadas/creadas:**
- Nueva: `pantalla_palabra_cliente.dart`
- Nueva: `pantalla_debate_cliente.dart`
- Nueva: `pantalla_votacion_cliente.dart`
- Modificar: `pantalla_unirse.dart` (registrar listener y reaccionar)
### Cambio 3: Impostor con Random seguro
Reemplazar `Random()` por `Random.secure()` en `EstadoJuego.crearPartida()`.
**Archivos afectados:**
- `lib/estado/estado_juego.dart`
---
## 3. Spec
### 3.1 PantallaGestorHost
**Props:**
```dart
class PantallaGestorHost extends StatelessWidget {
final VoidCallback onPartidaFin;
}
```
**Responsabilidades:**
- Escuchar cambios de `EstadoJuego` (fase actual)
- Escuchar cambios de `ServicioNearby` (desconexiones)
- Botones para avanzar de fase
- Mostrar estado de cada jugador (en debate, votando, eliminado)
- NO mostrar palabras a nadie
**Estados:**
- `debate`: muestra timer si hay tiempo configurado, botón "Iniciar votación"
- `votacion`: muestra quién ha votado, botón "Ver resultados" cuando todos voted
- `resultado`: muestra quién fue eliminado y si era impostor
- `adivinanza`: el impostor剩余 intenta adivinar
- `finPartida`: muestra ganador
### 3.2 PantallaPalabraCliente
**Props:**
```dart
class PantallaPalabraCliente extends StatelessWidget {
final String palabra;
final bool esImpostor;
final String? pistaCategoria;
final VoidCallback onVisto;
}
```
**Responsabilidades:**
- Mostrar la palabra solo al jugador correspondiente
- Si es impostor: mostrar pista de categoría (si está activa)
- Mantener pulsado para revelar (mismo UX que `_PantallaRevelarPalabra`)
- Al pulsar "Ya la he visto" → enviar `listo` al host via Nearby
- Esperar al host que inicie el debate
### 3.3 Sincronización de fases
**Protocolo de mensajes Via Nearby:**
```
HOST → CLIENTE:
partidaInicio → { palabra, esImpostor, categoria, numJugadores }
fase → { fase: "debate"|"votacion"|"resultado"|"adivinanza"|"fin" }
votacionResultado → { eliminadoId, eliminadoNombre, eraImpostor, votos }
impostorAdivina → { acierto: bool }
partidaFin → { ganador: "jugadores"|"impostores" }
CLIENTE → HOST:
listo → {} (el cliente ha visto su palabra y está listo)
voto → { votoporId }
```
### 3.4 Random seguro para impostores
```dart
// ANTES (predecible):
final rng = Random();
final indices = List.generate(jugadores.length, (i) => i);
indices.shuffle(rng);
// DESPUÉS (seguro):
final rng = Random.secure();
final impostoresElegidos = <int>{};
while (impostoresElegidos.length < numImpostores) {
impostoresElegidos.add(rng.nextInt(jugadores.length));
}
```
---
## 4. Tasks
### Epic 1: Impostor seguro
- [ ] **Task 1.1**: Cambiar `Random()` por `Random.secure()` en `EstadoJuego.crearPartida()`
### Epic 2: PantallaGestorHost
- [ ] **Task 2.1**: Crear `pantalla_gestor_host.dart` con estructura de fases
- [ ] **Task 2.2**: Implementar transición `lobby → gestor` en `pantalla_crear_partida.dart`
- [ ] **Task 2.3**: Implementar envío de cambio de fase a clientes
- [ ] **Task 2.4**: Gestionar desconexiones de jugadores durante la partida
### Epic 3: Cliente recibe inicio de partida
- [ ] **Task 3.1**: Registrar listener en `PantallaUnirse` para `partidaInicio`
- [ ] **Task 3.2**: Crear `pantalla_palabra_cliente.dart` (similar a `_PantallaRevelarPalabra` pero StatelessWidget)
- [ ] **Task 3.3**: Enviar mensaje `listo` al host cuando el cliente confirma que ha visto la palabra
### Epic 4: Cliente en fases de juego
- [ ] **Task 4.1**: Crear `pantalla_debate_cliente.dart` (muestra countdown, botón para solicitar votación)
- [ ] **Task 4.2**: Crear `pantalla_votacion_cliente.dart` (votar desde el móvil del cliente)
- [ ] **Task 4.3**: Cliente reacciona a `fase: votacion` y muestra pantalla de voto
- [ ] **Task 4.4**: Cliente reacciona a `fase: resultado` y muestra quién fue eliminado
- [ ] **Task 4.5**: Si el cliente es impostor剩余, mostrar pantalla de adivinanza
### Epic 5: Testing
- [ ] **Task 5.1**: Probar flujo completo multi-device (3+ jugadores)
- [ ] **Task 5.2**: Probar desconexión de un jugador durante la partida
- [ ] **Task 5.3**: Probar que en modo un solo móvil no se rompe nada
---
## 5. Apply
*(Ejecución por agente OpenCode)*
---
## 6. Verify
- [ ] Flujo multi-device: host inicia → clientes ven sus palabras → debate → votan → resultado
- [ ] Modo un solo móvil sigue funcionando exactamente igual que antes
- [ ] Impostores se seleccionan con Random.secure() — no reproducibles
- [ ] Sin regresiones en localized strings

View File

@@ -33,8 +33,6 @@ class EstadoJuego extends ChangeNotifier {
if (_banco == null) return; if (_banco == null) return;
if (nombresJugadores.length < 3) return; if (nombresJugadores.length < 3) return;
final rng = Random();
// Seleccionar palabra // Seleccionar palabra
final palabra = _banco!.palabraAleatoria(config.categoria); final palabra = _banco!.palabraAleatoria(config.categoria);
final categoriaReal = final categoriaReal =
@@ -42,19 +40,18 @@ class EstadoJuego extends ChangeNotifier {
// Crear jugadores // Crear jugadores
final jugadores = nombresJugadores.asMap().entries.map((e) { final jugadores = nombresJugadores.asMap().entries.map((e) {
return Jugador( return Jugador(id: 'j${e.key}', nombre: e.value);
id: 'j${e.key}',
nombre: e.value,
);
}).toList(); }).toList();
// Asignar impostores aleatoriamente // Asignar impostores usando Random seguro (no predecible)
final indices = List.generate(jugadores.length, (i) => i); final rng = Random.secure();
indices.shuffle(rng); final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3);
final numImpostores = final impostoresElegidos = <int>{};
config.numImpostores.clamp(1, jugadores.length ~/ 3); while (impostoresElegidos.length < numImpostores) {
for (int i = 0; i < numImpostores; i++) { impostoresElegidos.add(rng.nextInt(jugadores.length));
jugadores[indices[i]].esImpostor = true; }
for (final i in impostoresElegidos) {
jugadores[i].esImpostor = true;
} }
// Asignar palabras // Asignar palabras
@@ -124,15 +121,17 @@ class EstadoJuego extends ChangeNotifier {
// Encontrar máximo // Encontrar máximo
final maxVotos = conteo.values.reduce(max); final maxVotos = conteo.values.reduce(max);
final masVotados = final masVotados = conteo.entries
conteo.entries.where((e) => e.value == maxVotos).toList(); .where((e) => e.value == maxVotos)
.toList();
// En caso de empate, elegir aleatoriamente // En caso de empate, elegir aleatoriamente (usar Random.secure para consistencia)
final rng = Random(); final rng = Random.secure();
final eliminadoId = final eliminadoId = masVotados[rng.nextInt(masVotados.length)].key;
masVotados[rng.nextInt(masVotados.length)].key;
final eliminado = _partida!.jugadores.firstWhere((j) => j.id == eliminadoId); final eliminado = _partida!.jugadores.firstWhere(
(j) => j.id == eliminadoId,
);
eliminado.eliminado = true; eliminado.eliminado = true;
final resultado = ResultadoVotacion( final resultado = ResultadoVotacion(

View File

@@ -40,6 +40,7 @@
"impostors": "🎭 Impostores", "impostors": "🎭 Impostores",
"impostorClue": "🔍 Pista para impostor", "impostorClue": "🔍 Pista para impostor",
"impostorClueDescription": "El impostor conoce la categoría", "impostorClueDescription": "El impostor conoce la categoría",
"debate": "🗣️ Debate",
"debateTime": "⏱️ Tiempo de debate", "debateTime": "⏱️ Tiempo de debate",
"noLimit": "Sin límite", "noLimit": "Sin límite",
"oneMin": "1 min", "oneMin": "1 min",
@@ -232,6 +233,11 @@
"licenses": "Licencias", "licenses": "Licencias",
"scanToJoin": "Escanea el QR para unirte", "scanToJoin": "Escanea el QR para unirte",
"connectedPlayers": "Jugadores conectados", "connectedPlayers": "Jugadores conectados",
"hostGame": "Gestor de partida",
"waitingPlayersSeeWord": "Esperando que todos vean su palabra...",
"activePlayers": "Jugadores activos",
"playersVoted": "Han votado",
"waitingVoting": "Esperando que voten...",
"waitingForPlayers": "Esperando jugadores...", "waitingForPlayers": "Esperando jugadores...",
"needMorePlayers": "Faltan {count} jugadores más", "needMorePlayers": "Faltan {count} jugadores más",
"@needMorePlayers": { "@needMorePlayers": {
@@ -255,5 +261,21 @@
"searchingGames": "Buscando partidas cercanas...", "searchingGames": "Buscando partidas cercanas...",
"noGamesFound": "No se encontraron partidas", "noGamesFound": "No se encontraron partidas",
"noGamesFoundHint": "Asegúrate de que el host tiene la sala abierta y estáis cerca", "noGamesFoundHint": "Asegúrate de que el host tiene la sala abierta y estáis cerca",
"orScanQR": "¿No aparece? Escanea el QR del host" "orScanQR": "¿No aparece? Escanea el QR del host",
"iveSeenIt": "Ya la he visto",
"clueIs": "La pista es: {category}",
"@clueIs": {
"placeholders": {
"category": {
"type": "String"
}
}
},
"debatePhaseActive": "Fase de debate activa",
"debateInstructions": "Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.",
"solicitarVotacion": "Solicitar votación",
"votacionSolicitada": "Votación solicitada",
"whoDoYouThinkIsTheImpostor": "¿Quién es el impostor?",
"selectOnePlayer": "Selecciona a un jugador para votar",
"votar": "Votar"
} }

View File

@@ -327,6 +327,12 @@ abstract class AppLocalizations {
/// **'El impostor conoce la categoría'** /// **'El impostor conoce la categoría'**
String get impostorClueDescription; String get impostorClueDescription;
/// No description provided for @debate.
///
/// In es, this message translates to:
/// **'🗣️ Debate'**
String get debate;
/// No description provided for @debateTime. /// No description provided for @debateTime.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
@@ -1005,6 +1011,36 @@ abstract class AppLocalizations {
/// **'Jugadores conectados'** /// **'Jugadores conectados'**
String get connectedPlayers; String get connectedPlayers;
/// No description provided for @hostGame.
///
/// In es, this message translates to:
/// **'Gestor de partida'**
String get hostGame;
/// No description provided for @waitingPlayersSeeWord.
///
/// In es, this message translates to:
/// **'Esperando que todos vean su palabra...'**
String get waitingPlayersSeeWord;
/// No description provided for @activePlayers.
///
/// In es, this message translates to:
/// **'Jugadores activos'**
String get activePlayers;
/// No description provided for @playersVoted.
///
/// In es, this message translates to:
/// **'Han votado'**
String get playersVoted;
/// No description provided for @waitingVoting.
///
/// In es, this message translates to:
/// **'Esperando que voten...'**
String get waitingVoting;
/// No description provided for @waitingForPlayers. /// No description provided for @waitingForPlayers.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
@@ -1106,6 +1142,60 @@ abstract class AppLocalizations {
/// In es, this message translates to: /// In es, this message translates to:
/// **'¿No aparece? Escanea el QR del host'** /// **'¿No aparece? Escanea el QR del host'**
String get orScanQR; String get orScanQR;
/// No description provided for @iveSeenIt.
///
/// In es, this message translates to:
/// **'Ya la he visto'**
String get iveSeenIt;
/// No description provided for @clueIs.
///
/// In es, this message translates to:
/// **'La pista es: {category}'**
String clueIs(String category);
/// No description provided for @debatePhaseActive.
///
/// In es, this message translates to:
/// **'Fase de debate activa'**
String get debatePhaseActive;
/// No description provided for @debateInstructions.
///
/// In es, this message translates to:
/// **'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.'**
String get debateInstructions;
/// No description provided for @solicitarVotacion.
///
/// In es, this message translates to:
/// **'Solicitar votación'**
String get solicitarVotacion;
/// No description provided for @votacionSolicitada.
///
/// In es, this message translates to:
/// **'Votación solicitada'**
String get votacionSolicitada;
/// No description provided for @whoDoYouThinkIsTheImpostor.
///
/// In es, this message translates to:
/// **'¿Quién es el impostor?'**
String get whoDoYouThinkIsTheImpostor;
/// No description provided for @selectOnePlayer.
///
/// In es, this message translates to:
/// **'Selecciona a un jugador para votar'**
String get selectOnePlayer;
/// No description provided for @votar.
///
/// In es, this message translates to:
/// **'Votar'**
String get votar;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -109,6 +109,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get impostorClueDescription => 'المنتحل يعرف الفئة'; String get impostorClueDescription => 'المنتحل يعرف الفئة';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ وقت النقاش'; String get debateTime => '⏱️ وقت النقاش';
@@ -477,6 +480,21 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -531,4 +549,34 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsCa extends AppLocalizations {
@override @override
String get impostorClueDescription => 'L\'impostor coneix la categoria'; String get impostorClueDescription => 'L\'impostor coneix la categoria';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Temps de debat'; String get debateTime => '⏱️ Temps de debat';
@@ -480,6 +483,21 @@ class AppLocalizationsCa extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -534,4 +552,34 @@ class AppLocalizationsCa extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -110,6 +110,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get impostorClueDescription => 'Der Hochstapler kennt die Kategorie'; String get impostorClueDescription => 'Der Hochstapler kennt die Kategorie';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Diskussionszeit'; String get debateTime => '⏱️ Diskussionszeit';
@@ -483,6 +486,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -537,4 +555,34 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get impostorClueDescription => 'The impostor knows the category'; String get impostorClueDescription => 'The impostor knows the category';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Discussion time'; String get debateTime => '⏱️ Discussion time';
@@ -478,6 +481,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get connectedPlayers => 'Connected players'; String get connectedPlayers => 'Connected players';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Waiting for players...'; String get waitingForPlayers => 'Waiting for players...';
@@ -531,4 +549,34 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get orScanQR => 'Not showing up? Scan the host\'s QR code'; String get orScanQR => 'Not showing up? Scan the host\'s QR code';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get impostorClueDescription => 'El impostor conoce la categoría'; String get impostorClueDescription => 'El impostor conoce la categoría';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Tiempo de debate'; String get debateTime => '⏱️ Tiempo de debate';
@@ -479,6 +482,21 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -533,4 +551,34 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -110,6 +110,9 @@ class AppLocalizationsEu extends AppLocalizations {
@override @override
String get impostorClueDescription => 'Inpostoreak kategoria ezagutzen du'; String get impostorClueDescription => 'Inpostoreak kategoria ezagutzen du';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Eztabaida-denbora'; String get debateTime => '⏱️ Eztabaida-denbora';
@@ -482,6 +485,21 @@ class AppLocalizationsEu extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -536,4 +554,34 @@ class AppLocalizationsEu extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get impostorClueDescription => 'L\'imposteur connaît la catégorie'; String get impostorClueDescription => 'L\'imposteur connaît la catégorie';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Temps de débat'; String get debateTime => '⏱️ Temps de débat';
@@ -480,6 +483,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -534,4 +552,34 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get impostorClueDescription => 'धोखेबाज़ को श्रेणी पता होगी'; String get impostorClueDescription => 'धोखेबाज़ को श्रेणी पता होगी';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ बहस का समय'; String get debateTime => '⏱️ बहस का समय';
@@ -479,6 +482,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -533,4 +551,34 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get impostorClueDescription => 'L\'impostore conosce la categoria'; String get impostorClueDescription => 'L\'impostore conosce la categoria';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Tempo di discussione'; String get debateTime => '⏱️ Tempo di discussione';
@@ -480,6 +483,21 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -534,4 +552,34 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get impostorClueDescription => 'インポスターにカテゴリーが表示されます'; String get impostorClueDescription => 'インポスターにカテゴリーが表示されます';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ 議論の時間'; String get debateTime => '⏱️ 議論の時間';
@@ -477,6 +480,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -531,4 +549,34 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get impostorClueDescription => '임포스터가 카테고리를 알 수 있습니다'; String get impostorClueDescription => '임포스터가 카테고리를 알 수 있습니다';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ 토론 시간'; String get debateTime => '⏱️ 토론 시간';
@@ -477,6 +480,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -531,4 +549,34 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get impostorClueDescription => 'De bedrieger kent de categorie'; String get impostorClueDescription => 'De bedrieger kent de categorie';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Debattijd'; String get debateTime => '⏱️ Debattijd';
@@ -480,6 +483,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -534,4 +552,34 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get impostorClueDescription => 'Oszust zna kategorię'; String get impostorClueDescription => 'Oszust zna kategorię';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Czas debaty'; String get debateTime => '⏱️ Czas debaty';
@@ -480,6 +483,21 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -534,4 +552,34 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get impostorClueDescription => 'O impostor conhece a categoria'; String get impostorClueDescription => 'O impostor conhece a categoria';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Tempo de debate'; String get debateTime => '⏱️ Tempo de debate';
@@ -481,6 +484,21 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -535,4 +553,34 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get impostorClueDescription => 'Самозванец знает категорию'; String get impostorClueDescription => 'Самозванец знает категорию';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Время обсуждения'; String get debateTime => '⏱️ Время обсуждения';
@@ -480,6 +483,21 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -534,4 +552,34 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get impostorClueDescription => 'Sahtekar kategoriyi bilir'; String get impostorClueDescription => 'Sahtekar kategoriyi bilir';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ Tartışma süresi'; String get debateTime => '⏱️ Tartışma süresi';
@@ -479,6 +482,21 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -533,4 +551,34 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }

View File

@@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get impostorClueDescription => '冒牌者可以知道分类'; String get impostorClueDescription => '冒牌者可以知道分类';
@override
String get debate => '🗣️ Debate';
@override @override
String get debateTime => '⏱️ 讨论时间'; String get debateTime => '⏱️ 讨论时间';
@@ -476,6 +479,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get connectedPlayers => 'Jugadores conectados'; String get connectedPlayers => 'Jugadores conectados';
@override
String get hostGame => 'Gestor de partida';
@override
String get waitingPlayersSeeWord => 'Esperando que todos vean su palabra...';
@override
String get activePlayers => 'Jugadores activos';
@override
String get playersVoted => 'Han votado';
@override
String get waitingVoting => 'Esperando que voten...';
@override @override
String get waitingForPlayers => 'Esperando jugadores...'; String get waitingForPlayers => 'Esperando jugadores...';
@@ -530,6 +548,36 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get orScanQR => '¿No aparece? Escanea el QR del host'; String get orScanQR => '¿No aparece? Escanea el QR del host';
@override
String get iveSeenIt => 'Ya la he visto';
@override
String clueIs(String category) {
return 'La pista es: $category';
}
@override
String get debatePhaseActive => 'Fase de debate activa';
@override
String get debateInstructions =>
'Hablad entre vosotros y decid quién creéis que es el impostor. Cuando estéis listos, solicitad la votación.';
@override
String get solicitarVotacion => 'Solicitar votación';
@override
String get votacionSolicitada => 'Votación solicitada';
@override
String get whoDoYouThinkIsTheImpostor => '¿Quién es el impostor?';
@override
String get selectOnePlayer => 'Selecciona a un jugador para votar';
@override
String get votar => 'Votar';
} }
/// The translations for Chinese, as used in Taiwan (`zh_TW`). /// The translations for Chinese, as used in Taiwan (`zh_TW`).

View File

@@ -7,7 +7,9 @@ import '../modelos/partida.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart'; import '../servicios/servicio_permisos.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_gestor_host.dart';
import 'pantalla_lobby_host.dart'; import 'pantalla_lobby_host.dart';
import 'pantalla_principal.dart';
import 'pantalla_ver_palabra.dart'; import 'pantalla_ver_palabra.dart';
class PantallaCrearPartida extends StatefulWidget { class PantallaCrearPartida extends StatefulWidget {
@@ -30,23 +32,28 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
int get _maxImpostores => (_jugadores.length / 3).floor().clamp(1, 4); int get _maxImpostores => (_jugadores.length / 3).floor().clamp(1, 4);
List<String> _etiquetasTiempo(AppLocalizations l10n) => List<String> _etiquetasTiempo(AppLocalizations l10n) => [
[l10n.noLimit, l10n.oneMin, l10n.twoMin, l10n.threeMin, l10n.fiveMin]; l10n.noLimit,
l10n.oneMin,
l10n.twoMin,
l10n.threeMin,
l10n.fiveMin,
];
void _agregarJugador() { void _agregarJugador() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final nombre = _controladorNombre.text.trim(); final nombre = _controladorNombre.text.trim();
if (nombre.isEmpty) return; if (nombre.isEmpty) return;
if (_jugadores.contains(nombre)) { if (_jugadores.contains(nombre)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l10n.playerAlreadyExists)), context,
); ).showSnackBar(SnackBar(content: Text(l10n.playerAlreadyExists)));
return; return;
} }
if (_jugadores.length >= 20) { if (_jugadores.length >= 20) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l10n.maxPlayersReached)), context,
); ).showSnackBar(SnackBar(content: Text(l10n.maxPlayersReached)));
return; return;
} }
setState(() { setState(() {
@@ -76,9 +83,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
} }
if (_jugadores.length < 3) { if (_jugadores.length < 3) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l10n.minPlayersRequired)), context,
); ).showSnackBar(SnackBar(content: Text(l10n.minPlayersRequired)));
return; return;
} }
@@ -106,7 +113,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
if (!permisosOk) { if (!permisosOk) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Se necesitan permisos de Bluetooth y ubicación')), const SnackBar(
content: Text('Se necesitan permisos de Bluetooth y ubicación'),
),
); );
} }
return; return;
@@ -125,7 +134,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
if (!ok) { if (!ok) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo crear la sala. Verifica Bluetooth.')), const SnackBar(
content: Text('No se pudo crear la sala. Verifica Bluetooth.'),
),
); );
} }
return; return;
@@ -163,7 +174,8 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
final jugadorNearby = nearby.jugadores[i]; final jugadorNearby = nearby.jugadores[i];
// El jugador [0] es el host, los de nearby son [1..n] // El jugador [0] es el host, los de nearby son [1..n]
final jugadorPartida = partida.jugadores[i + 1]; final jugadorPartida = partida.jugadores[i + 1];
impostores[jugadorNearby.endpointId] = jugadorPartida.esImpostor; impostores[jugadorNearby.endpointId] =
jugadorPartida.esImpostor;
} }
nearby.enviarInicioPartida( nearby.enviarInicioPartida(
@@ -174,7 +186,19 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute(builder: (_) => const PantallaVerPalabra()), MaterialPageRoute(
builder: (_) => PantallaGestorHost(
onPartidaFin: () {
estado.limpiar();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaPrincipal(),
),
);
},
),
),
); );
}, },
), ),
@@ -241,8 +265,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.gameMode, Text(
style: Theme.of(context).textTheme.titleLarge), l10n.gameMode,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12), const SizedBox(height: 12),
SegmentedButton<bool>( SegmentedButton<bool>(
segments: [ segments: [
@@ -275,8 +301,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.category, Text(
style: Theme.of(context).textTheme.titleLarge), l10n.category,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -288,7 +316,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
items: categorias.map((c) { items: categorias.map((c) {
return DropdownMenuItem( return DropdownMenuItem(
value: c, value: c,
child: Text(BancoPalabras.nombreBonitoCategoria(c, l10n)), child: Text(
BancoPalabras.nombreBonitoCategoria(c, l10n),
),
); );
}).toList(), }).toList(),
onChanged: (v) => setState(() => _categoria = v!), onChanged: (v) => setState(() => _categoria = v!),
@@ -310,10 +340,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(l10n.playersCount(_jugadores.length), Text(
style: Theme.of(context).textTheme.titleLarge), l10n.playersCount(_jugadores.length),
Text(l10n.playersRangeHint, style: Theme.of(context).textTheme.titleLarge,
style: Theme.of(context).textTheme.bodyMedium), ),
Text(
l10n.playersRangeHint,
style: Theme.of(context).textTheme.bodyMedium,
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -342,13 +376,17 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: TemaApp.colorTarjeta, backgroundColor: TemaApp.colorTarjeta,
child: Text('${e.key + 1}', child: Text(
style: '${e.key + 1}',
const TextStyle(color: TemaApp.colorTexto)), style: const TextStyle(color: TemaApp.colorTexto),
),
), ),
title: Text(e.value), title: Text(e.value),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.close, color: TemaApp.colorAcento), icon: const Icon(
Icons.close,
color: TemaApp.colorAcento,
),
onPressed: () => _eliminarJugador(e.key), onPressed: () => _eliminarJugador(e.key),
), ),
dense: true, dense: true,
@@ -367,8 +405,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.configuration, Text(
style: Theme.of(context).textTheme.titleLarge), l10n.configuration,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12), const SizedBox(height: 12),
// Número de impostores // Número de impostores
@@ -384,10 +424,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
: null, : null,
icon: const Icon(Icons.remove_circle_outline), icon: const Icon(Icons.remove_circle_outline),
), ),
Text('$_numImpostores', Text(
style: Theme.of(context) '$_numImpostores',
.textTheme style: Theme.of(context).textTheme.titleLarge,
.titleLarge), ),
IconButton( IconButton(
onPressed: _numImpostores < _maxImpostores onPressed: _numImpostores < _maxImpostores
? () => setState(() => _numImpostores++) ? () => setState(() => _numImpostores++)
@@ -404,8 +444,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
title: Text(l10n.impostorClue), title: Text(l10n.impostorClue),
subtitle: Text(l10n.impostorClueDescription), subtitle: Text(l10n.impostorClueDescription),
value: _pistaImpostor, value: _pistaImpostor,
onChanged: (v) => onChanged: (v) => setState(() => _pistaImpostor = v),
setState(() => _pistaImpostor = v),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
@@ -423,8 +462,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
child: Text(etiquetas[i]), child: Text(etiquetas[i]),
), ),
), ),
onChanged: (v) => onChanged: (v) => setState(() => _tiempoDebate = v),
setState(() => _tiempoDebate = v),
), ),
], ],
), ),
@@ -439,7 +477,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
width: double.infinity, width: double.infinity,
height: 56, height: 56,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: (_modoMultimovil || _jugadores.length >= 3) ? _iniciarPartida : null, onPressed: (_modoMultimovil || _jugadores.length >= 3)
? _iniciarPartida
: null,
icon: const Icon(Icons.play_arrow), icon: const Icon(Icons.play_arrow),
label: Text(l10n.startGame), label: Text(l10n.startGame),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(

View File

@@ -0,0 +1,156 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/tema/tema_app.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 VoidCallback onSolicitarVotacion;
const PantallaDebateCliente({
super.key,
this.tiempoDebateSegundos,
required this.onSolicitarVotacion,
});
@override
State<PantallaDebateCliente> createState() => _PantallaDebateClienteState();
}
class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
Timer? _timer;
int _segundosRestantes = 0;
bool _votacionSolicitada = false;
@override
void initState() {
super.initState();
if (widget.tiempoDebateSegundos != null) {
_segundosRestantes = widget.tiempoDebateSegundos!;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_segundosRestantes > 0) {
setState(() => _segundosRestantes--);
} else {
timer.cancel();
}
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String _formatearTiempo(int segundos) {
final min = segundos ~/ 60;
final seg = segundos % 60;
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: TemaApp.colorFondo,
appBar: AppBar(
title: Text(l10n.debate),
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Spacer(),
// Timer
if (widget.tiempoDebateSegundos != null) ...[
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: _segundosRestantes == 0
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(24),
),
child: Column(
children: [
Text(
_segundosRestantes == 0
? l10n.timeUp
: l10n.timeRemaining,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_formatearTiempo(_segundosRestantes),
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
color: _segundosRestantes == 0
? TemaApp.colorAcento
: TemaApp.colorTexto,
),
),
],
),
),
const SizedBox(height: 32),
] else ...[
Text(
l10n.debatePhaseActive,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
// Instrucciones
Text(
l10n.debateInstructions,
textAlign: TextAlign.center,
style: TextStyle(
color: TemaApp.colorTextoSecundario,
fontSize: 16,
),
),
const Spacer(),
// Botón solicitar votación
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _votacionSolicitada
? null
: () {
setState(() => _votacionSolicitada = true);
widget.onSolicitarVotacion();
},
icon: Icon(_votacionSolicitada ? Icons.hourglass_empty : Icons.how_to_vote),
label: Text(
_votacionSolicitada
? l10n.votacionSolicitada
: l10n.solicitarVotacion,
),
style: ElevatedButton.styleFrom(
backgroundColor: _votacionSolicitada
? TemaApp.colorTarjeta
: TemaApp.colorAcento,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,514 @@
import 'dart:async';
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/partida.dart';
import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.dart';
class PantallaGestorHost extends StatefulWidget {
final VoidCallback onPartidaFin;
const PantallaGestorHost({super.key, required this.onPartidaFin});
@override
State<PantallaGestorHost> createState() => _PantallaGestorHostState();
}
class _PantallaGestorHostState extends State<PantallaGestorHost> {
Timer? _timer;
int _segundosRestantes = 0;
final Map<String, bool> _clientesListos = {};
final Map<String, String> _votosRecibidos = {};
@override
void initState() {
super.initState();
_iniciarTemporizador();
_registrarListeners();
}
void _iniciarTemporizador() {
final estado = context.read<EstadoJuego>();
final tiempo = estado.partida?.config.tiempoDebateSegundos;
if (tiempo != null) {
_segundosRestantes = tiempo;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_segundosRestantes > 0) {
setState(() => _segundosRestantes--);
} else {
timer.cancel();
}
});
}
}
void _registrarListeners() {
final nearby = context.read<ServicioNearby>();
nearby.onMensaje((endpointId, mensaje) {
if (mensaje.tipo == TipoMensaje.listo) {
setState(() => _clientesListos[endpointId] = true);
} else if (mensaje.tipo == TipoMensaje.voto) {
final votanteId = mensaje.datos['votanteId'] as String?;
final votoId = mensaje.datos['votoporId'] as String?;
if (votanteId != null && votoId != null) {
setState(() => _votosRecibidos[votanteId] = votoId);
}
}
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String _formatearTiempo(int segundos) {
final min = segundos ~/ 60;
final seg = segundos % 60;
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
}
void _avanzarAFase(FaseJuego fase) {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
switch (fase) {
case FaseJuego.debate:
estado.iniciarDebate();
nearby.enviarCambioFase('debate');
_iniciarTemporizador();
break;
case FaseJuego.votacion:
estado.iniciarVotacion();
nearby.enviarCambioFase('votacion');
_votosRecibidos.clear();
break;
case FaseJuego.resultado:
final resultado = estado.procesarVotacion();
if (resultado != null) {
nearby.enviarResultadoVotacion({
'eliminadoId': resultado.eliminadoId,
'eliminadoNombre': resultado.eliminadoNombre,
'eraImpostor': resultado.eraImpostor,
'votos': resultado.votos,
});
}
break;
default:
break;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final estado = context.watch<EstadoJuego>();
final nearby = context.watch<ServicioNearby>();
final partida = estado.partida;
if (partida == null) {
return Scaffold(
appBar: AppBar(title: Text(l10n.hostGame)),
body: const Center(child: Text('Error: Sin partida')),
);
}
final numJugadores = partida.jugadores.length + 1;
final todosListos = _clientesListos.length >= numJugadores - 1;
final todosVotaron = _votosRecibidos.length >= numJugadores - 1;
return Scaffold(
appBar: AppBar(
title: Text(l10n.hostGame),
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () async {
await nearby.desconectar();
widget.onPartidaFin();
},
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildFaseIndicator(context, partida.fase, l10n),
const SizedBox(height: 16),
Expanded(
child: _buildContenidoFase(
context,
partida.fase,
l10n,
todosListos,
todosVotaron,
),
),
const SizedBox(height: 16),
_buildBotonAccion(
context,
partida.fase,
l10n,
todosListos,
todosVotaron,
),
],
),
),
);
}
Widget _buildFaseIndicator(
BuildContext context,
FaseJuego fase,
AppLocalizations l10n,
) {
final fases = [
(FaseJuego.verPalabra, l10n.seeYourWord),
(FaseJuego.debate, l10n.debate),
(FaseJuego.votacion, l10n.voting),
(FaseJuego.resultado, l10n.result),
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: fases.map((e) {
final esActiva = fase == e.$1 || fase.index > e.$1.index;
return Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: esActiva ? TemaApp.colorAcento : TemaApp.colorSuperficie,
borderRadius: BorderRadius.circular(20),
),
child: Text(
e.$2,
style: TextStyle(
color: esActiva ? Colors.white : TemaApp.colorTextoSecundario,
fontWeight: esActiva ? FontWeight.bold : FontWeight.normal,
),
),
);
}).toList(),
),
);
}
Widget _buildContenidoFase(
BuildContext context,
FaseJuego fase,
AppLocalizations l10n,
bool todosListos,
bool todosVotaron,
) {
final nearby = context.watch<ServicioNearby>();
switch (fase) {
case FaseJuego.verPalabra:
return _buildFaseVerPalabra(context, l10n, todosListos, nearby);
case FaseJuego.debate:
return _buildFaseDebate(context, l10n, nearby);
case FaseJuego.votacion:
return _buildFaseVotacion(context, l10n, todosVotaron, nearby);
default:
return const Center(child: Text('Fin de la partida'));
}
}
Widget _buildFaseVerPalabra(
BuildContext context,
AppLocalizations l10n,
bool todosListos,
ServicioNearby nearby,
) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.waitingPlayersSeeWord,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Text(
l10n.connectedPlayers,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_buildJugadorTile(nearby.miNombre ?? 'Host', true, false),
...nearby.jugadores.map(
(j) => _buildJugadorTile(
j.nombre,
false,
_clientesListos[j.endpointId] ?? false,
),
),
const Spacer(),
if (todosListos)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: TemaApp.colorVerde.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle, color: TemaApp.colorVerde),
const SizedBox(width: 8),
Text(
l10n.allSeenStartDebate,
style: const TextStyle(color: TemaApp.colorVerde),
),
],
),
),
],
),
),
);
}
Widget _buildFaseDebate(
BuildContext context,
AppLocalizations l10n,
ServicioNearby nearby,
) {
final estado = context.read<EstadoJuego>();
final tiempo = estado.partida?.config.tiempoDebateSegundos;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (tiempo != null) ...[
Text(l10n.debate, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _segundosRestantes == 0
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
_segundosRestantes == 0
? l10n.timeUp
: l10n.timeRemaining,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
_formatearTiempo(_segundosRestantes),
style: Theme.of(context).textTheme.headlineLarge,
),
],
),
),
const SizedBox(height: 16),
],
Text(
l10n.activePlayers,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: nearby.jugadores.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return _buildJugadorTile(
nearby.miNombre ?? 'Host',
true,
true,
);
}
final j = nearby.jugadores[index - 1];
return _buildJugadorTile(j.nombre, false, true);
},
),
),
],
),
),
);
}
Widget _buildFaseVotacion(
BuildContext context,
AppLocalizations l10n,
bool todosVotaron,
ServicioNearby nearby,
) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.voting, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
l10n.votesProgress(
_votosRecibidos.length,
nearby.jugadores.length + 1,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value:
_votosRecibidos.length /
(nearby.jugadores.length + 1),
backgroundColor: TemaApp.colorSuperficie,
valueColor: const AlwaysStoppedAnimation(
TemaApp.colorAcento,
),
minHeight: 8,
),
),
],
),
),
const SizedBox(height: 16),
Text(
l10n.playersVoted,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: nearby.jugadores.length + 1,
itemBuilder: (context, index) {
final esHost = index == 0;
final nombre = esHost
? (nearby.miNombre ?? 'Host')
: nearby.jugadores[index - 1].nombre;
final haVotado =
esHost || _votosRecibidos.containsKey(nombre);
return _buildJugadorTile(nombre, esHost, haVotado);
},
),
),
if (todosVotaron)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: TemaApp.colorVerde.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle, color: TemaApp.colorVerde),
const SizedBox(width: 8),
Text(
l10n.allVoted,
style: const TextStyle(color: TemaApp.colorVerde),
),
],
),
),
],
),
),
);
}
Widget _buildJugadorTile(String nombre, bool esHost, bool listo) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: listo
? TemaApp.colorVerde.withValues(alpha: 0.2)
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 18)),
const SizedBox(width: 8),
Expanded(child: Text(nombre)),
if (listo)
const Icon(Icons.check_circle, color: TemaApp.colorVerde, size: 20),
],
),
);
}
Widget _buildBotonAccion(
BuildContext context,
FaseJuego fase,
AppLocalizations l10n,
bool todosListos,
bool todosVotaron,
) {
switch (fase) {
case FaseJuego.verPalabra:
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: todosListos
? () => _avanzarAFase(FaseJuego.debate)
: null,
icon: const Icon(Icons.forum),
label: Text(
todosListos
? l10n.allSeenStartDebate
: l10n.waitingPlayersSeeWord,
),
),
);
case FaseJuego.debate:
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _avanzarAFase(FaseJuego.votacion),
icon: const Icon(Icons.how_to_vote),
label: Text(l10n.goToVoting),
),
);
case FaseJuego.votacion:
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: todosVotaron
? () => _avanzarAFase(FaseJuego.resultado)
: null,
icon: const Icon(Icons.visibility),
label: Text(todosVotaron ? l10n.revealResult : l10n.waitingVoting),
),
);
default:
return const SizedBox.shrink();
}
}
}

View File

@@ -0,0 +1,169 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/tema/tema_app.dart';
/// Pantalla que ve cada jugador cuando recibe su palabra (modo multidispositivo).
/// El cliente recibe la palabra via ServicioNearby y se navega aquí.
/// NO es la pantalla del host.
class PantallaPalabraCliente extends StatefulWidget {
final String palabra;
final bool esImpostor;
final String? pistaCategoria;
final VoidCallback onVisto;
const PantallaPalabraCliente({
super.key,
required this.palabra,
required this.esImpostor,
this.pistaCategoria,
required this.onVisto,
});
@override
State<PantallaPalabraCliente> createState() => _PantallaPalabraClienteState();
}
class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
bool _palabraVisible = false;
Timer? _timer;
void _togglePalabra() {
setState(() => _palabraVisible = !_palabraVisible);
_timer?.cancel();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: TemaApp.colorFondo,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Spacer(),
// Tarjeta de palabra
GestureDetector(
onTap: _togglePalabra,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24),
decoration: BoxDecoration(
color: _palabraVisible
? TemaApp.colorAcento
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(24),
boxShadow: _palabraVisible
? [
BoxShadow(
color: TemaApp.colorAcento.withValues(alpha: 0.4),
blurRadius: 24,
spreadRadius: 2,
),
]
: null,
),
child: Column(
children: [
Icon(
_palabraVisible ? Icons.visibility : Icons.visibility_off,
color: _palabraVisible
? Colors.white
: TemaApp.colorTextoSecundario,
size: 32,
),
const SizedBox(height: 16),
Text(
_palabraVisible ? widget.palabra : '???',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: _palabraVisible
? Colors.white
: TemaApp.colorTextoSecundario,
),
),
],
),
),
),
const SizedBox(height: 16),
// Pista para impostores
if (widget.esImpostor && widget.pistaCategoria != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: TemaApp.colorAcento.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.lightbulb, color: TemaApp.colorAcento),
const SizedBox(width: 8),
Flexible(
child: Text(
'🎭 ${l10n.clueIs(widget.pistaCategoria!)}',
style: const TextStyle(color: TemaApp.colorAcento),
),
),
],
),
),
const SizedBox(height: 8),
],
// Instrucciones
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_palabraVisible
? 'Mantén la pantalla oculta. No la enseñes a nadie.'
: 'Toca para ver tu palabra',
textAlign: TextAlign.center,
style: TextStyle(
color: TemaApp.colorTextoSecundario,
fontSize: 14,
),
),
),
const Spacer(),
// Botón confirmar
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
widget.onVisto();
},
icon: const Icon(Icons.check),
label: Text(l10n.iveSeenIt),
style: ElevatedButton.styleFrom(
backgroundColor: TemaApp.colorAcento,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}

View File

@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/jugador.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart'; import '../servicios/servicio_permisos.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_palabra_cliente.dart';
import 'pantalla_debate_cliente.dart';
import 'pantalla_votacion_cliente.dart';
/// Pantalla para unirse a una partida multidispositivo. /// Pantalla para unirse a una partida multidispositivo.
/// Flujo: nombre → discovery automático (lista de salas) → fallback QR /// Flujo: nombre → discovery automático (lista de salas) → fallback QR
@@ -26,6 +30,110 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
String? _error; String? _error;
String? _salaSeleccionada; String? _salaSeleccionada;
// Estado del juego recibido del host
String? _palabraRecibida;
bool _esImpostor = false;
String? _pistaCategoria;
final List<Jugador> _jugadores = [];
@override
void initState() {
super.initState();
// Registrar listener ANTES del primer build
WidgetsBinding.instance.addPostFrameCallback((_) {
_registrarListenerPartida();
});
}
void _registrarListenerPartida() {
final nearby = context.read<ServicioNearby>();
nearby.onMensaje((endpointId, mensaje) {
if (mensaje.tipo == TipoMensaje.partidaInicio) {
// El host ha iniciado la partida — nos ha enviado nuestra palabra
setState(() {
_palabraRecibida = mensaje.datos['palabra'] as String?;
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
_pistaCategoria = mensaje.datos['categoria'] as String?;
});
// Navegar a pantalla de palabra del cliente
if (mounted && _palabraRecibida != null) {
_navegarAPalabra();
}
} else if (mensaje.tipo == TipoMensaje.fase) {
// El host cambia de fase — navegar a la pantalla correspondiente
final fase = mensaje.datos['fase'] as String?;
if (mounted && fase != null) {
_navegarSegunFase(fase);
}
}
});
}
void _navegarAPalabra() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PantallaPalabraCliente(
palabra: _palabraRecibida ?? '',
esImpostor: _esImpostor,
pistaCategoria: _pistaCategoria,
onVisto: () {
// Enviar "listo" al host y volver a la espera
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
);
}
Navigator.of(context).pop();
},
),
),
);
}
void _navegarSegunFase(String fase) {
switch (fase) {
case 'debate':
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaDebateCliente(
tiempoDebateSegundos: null,
onSolicitarVotacion: () {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.ping, datos: {'solicitoVotacion': true}),
);
}
},
),
),
);
break;
case 'votacion':
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaVotacionCliente(
jugadores: _jugadores,
onVoto: (votoporId) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.voto, datos: {'votoporId': votoporId}),
);
}
Navigator.of(context).pop();
},
),
),
);
break;
}
}
@override @override
void dispose() { void dispose() {
_nombreController.dispose(); _nombreController.dispose();

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/tema/tema_app.dart';
/// Pantalla de votación para el cliente (multidispositivo).
/// El cliente recibe fase=votacion y ve esta pantalla para elegir a quién votar.
class PantallaVotacionCliente extends StatefulWidget {
final List<Jugador> jugadores;
final Function(String votoporId) onVoto;
const PantallaVotacionCliente({
super.key,
required this.jugadores,
required this.onVoto,
});
@override
State<PantallaVotacionCliente> createState() => _PantallaVotacionClienteState();
}
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
String? _votoSeleccionado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: TemaApp.colorFondo,
appBar: AppBar(
title: Text(l10n.voting),
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.whoDoYouThinkIsTheImpostor,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
l10n.selectOnePlayer,
style: TextStyle(color: TemaApp.colorTextoSecundario),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: widget.jugadores.length,
itemBuilder: (context, index) {
final jugador = widget.jugadores[index];
final selected = _votoSeleccionado == jugador.id;
return Card(
color: selected
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorTarjeta,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: selected
? TemaApp.colorAcento
: TemaApp.colorAcento.withValues(alpha: 0.3),
child: Text(
'${index + 1}',
style: TextStyle(
color: selected
? Colors.white
: TemaApp.colorTexto,
),
),
),
title: Text(jugador.nombre),
trailing: selected
? const Icon(Icons.check_circle,
color: TemaApp.colorAcento)
: null,
onTap: () {
setState(() => _votoSeleccionado = jugador.id);
},
),
);
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _votoSeleccionado == null
? null
: () => widget.onVoto(_votoSeleccionado!),
icon: const Icon(Icons.how_to_vote),
label: Text(l10n.votar),
style: ElevatedButton.styleFrom(
backgroundColor: TemaApp.colorAcento,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
}