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(

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:provider/provider.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/jugador.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.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.
/// Flujo: nombre → discovery automático (lista de salas) → fallback QR
@@ -26,6 +30,110 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
String? _error;
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
void 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),
),
),
),
],
),
),
);
}
}