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

View File

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