Files
farolero/lib/pantallas/pantalla_gestor_host.dart
ShanaiaBot eb2662f561
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
fix: multidispositivo - Random seguro + gestor host + reacción clientes
- 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
2026-04-15 02:09:05 +02:00

515 lines
16 KiB
Dart

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();
}
}
}