Files
farolero/lib/pantallas/pantalla_gestor_host.dart
T
FreeTLab 56f07364c2
Build & Deploy Farolero / Análisis de código (push) Successful in 13s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m22s
feat: confirm before leaving a game, and fix untranslated strings
Exit confirmation
- A dialog in the app's own style now guards every in-game screen. The safe
  action is the prominent one, so a stray tap keeps you in the game.
- Eleven screens covered, against both the system back gesture and the close
  buttons. The message adapts: a single-device game is lost, a host ends it
  for everyone, a client can rejoin.
- The two screens the host also opens from the manager take a flag so that
  back there just closes them instead of offering to quit.

Translations
- 21 keys were still holding the English text in Arabic, Basque, Hindi,
  Japanese, Korean, Dutch, Polish, Russian, Turkish and both Chinese
  variants: 231 strings, covering the whole multi-device flow, permissions
  and the mode picker. Nothing failed, the app just showed English.
- The permissions dialog was hardcoded in Spanish; it is localized now.
- Portuguese used Spanish gerunds where pt-PT takes "a + infinitive".

Checked what looked untranslated but is not: Animals and Notes are correct
Catalan, Configuration and Vibration correct French, Version correct German.
Those are whitelisted in the new test rather than silently rewritten.

l10n_cobertura_test guards all of it: no missing or orphan keys, no empty
values, no English left outside the whitelist, and placeholders preserved.
2026-07-25 23:59:57 +02:00

1318 lines
45 KiB
Dart

import 'dart:async';
import '../tema/dialogo_confirmacion.dart';
import 'dart:math';
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/gamificacion_usuario.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart';
import '../modelos/palabra.dart';
import '../modelos/partida.dart';
import '../modelos/sala_multijugador.dart';
import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/componentes_resultado_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart';
import 'pantalla_revision_palabra.dart';
import 'pantalla_votacion_cliente.dart';
import 'pantalla_palabras_cliente.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;
bool _hostListo = false;
bool _partidaOnlineGuardada = false;
ProgresoGamificacionUsuario? _progresoGamificacion;
String? _primerTurnoId;
String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {};
final Map<String, String> _votosRecibidos = {};
OnMensajeCallback? _listenerMensajes;
ServicioNearby? _nearby;
@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 = nearby;
_listenerMensajes = (endpointId, mensaje) {
if (!mounted) return;
if (mensaje.tipo == TipoMensaje.listo) {
// Se indexa por clientId, no por endpointId: el endpoint cambia en
// cada reconexión y si no perderíamos el "ya la he visto".
final clientId = _clientIdDe(endpointId) ?? endpointId;
setState(() => _clientesListos[clientId] = true);
} else if (mensaje.tipo == TipoMensaje.unirse) {
// Con la partida ya en marcha, un `unirse` solo puede ser un móvil
// que vuelve tras caerse.
final nombre = mensaje.datos['nombre'] as String?;
if (nombre != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.playerRejoined(nombre)),
),
);
}
} else if (mensaje.tipo == TipoMensaje.solicitarResync) {
_responderResync(endpointId);
} else if (mensaje.tipo == TipoMensaje.voto) {
final votanteId = mensaje.datos['votanteId'] as String?;
final votoId =
mensaje.datos['votadoId'] as String? ??
mensaje.datos['votoporId'] as String?;
if (votanteId == null || votoId == null) return;
// Un jugador eliminado ya no vota, venga de donde venga el mensaje.
final partida = context.read<EstadoJuego>().partida;
final sigueVivo =
partida?.jugadoresActivos.any((j) => j.id == votanteId) ?? false;
if (!sigueVivo) return;
context.read<EstadoJuego>().registrarVoto(votanteId, votoId);
setState(() => _votosRecibidos[votanteId] = votoId);
}
};
nearby.onMensaje(_listenerMensajes!);
}
@override
void dispose() {
_timer?.cancel();
final listener = _listenerMensajes;
if (listener != null) _nearby?.removeMensajeListener(listener);
super.dispose();
}
String? _clientIdDe(String endpointId) {
return context
.read<ServicioNearby>()
.estadoSala
?.clientePorEndpoint(endpointId)
?.clientId;
}
/// Jugadores de la partida que controla un cliente concreto, con su palabra,
/// su rol y sus compañeros. Es lo que necesita un móvil para volver a jugar.
List<JugadorInicioPartida> _jugadoresDeCliente(
Partida partida,
EstadoSalaMultijugador sala,
String clientId,
) {
final estado = context.read<EstadoJuego>();
return sala
.usuariosPorCliente(clientId)
.where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
.map((usuario) {
final jugador = partida.jugadores.firstWhere(
(j) => j.id == usuario.id,
);
return JugadorInicioPartida(
jugadorId: jugador.id,
nombre: jugador.nombre,
esImpostor: jugador.esImpostor,
palabra: jugador.esImpostor ? null : partida.palabraSecreta,
companerosImpostores: estado.companerosImpostoresDe(jugador.id),
);
})
.toList();
}
/// Responde a un móvil que vuelve tras una caída con el estado completo:
/// en qué fase va la partida y qué jugadores le tocan.
Future<void> _responderResync(String endpointId) async {
final nearby = context.read<ServicioNearby>();
final estado = context.read<EstadoJuego>();
final partida = estado.partida;
final sala = nearby.estadoSala;
if (partida == null || sala == null) return;
final clientId = _clientIdDe(endpointId);
if (clientId == null) return;
final datos = _snapshot(fase: partida.fase.name).toJson();
datos['fase'] = partida.fase.name;
datos['jugadores'] = _jugadoresDeCliente(partida, sala, clientId)
.map((jugador) => jugador.toJson())
.toList();
if (partida.config.pistaImpostor) {
datos['pistaImpostor'] = partida.pistaImpostor;
}
if (partida.config.tiempoDebateSegundos != null) {
datos['tiempoDebateSegundos'] = partida.config.tiempoDebateSegundos;
}
if (_primerTurnoNombre != null) {
datos['primerTurnoNombre'] = _primerTurnoNombre;
}
await nearby.enviarResync(endpointId, datos);
if (mounted) setState(() {});
}
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>();
final l10n = AppLocalizations.of(context)!;
switch (fase) {
case FaseJuego.debate:
estado.iniciarDebate();
final primero = _elegirPrimerTurno();
nearby.enviarCambioFase('debate', {
..._snapshot(fase: 'debate').toJson(),
if (primero != null) ...{
'primerTurnoId': primero.id,
'primerTurnoNombre': primero.nombre,
},
if (estado.partida?.config.tiempoDebateSegundos != null)
'tiempoDebateSegundos': estado.partida!.config.tiempoDebateSegundos,
});
_iniciarTemporizador();
break;
case FaseJuego.votacion:
estado.iniciarVotacion();
nearby.enviarCambioFase('votacion', _snapshot(fase: 'votacion').toJson());
_votosRecibidos.clear();
break;
case FaseJuego.resultado:
final resultado = estado.procesarVotacion();
if (resultado != null) {
nearby.enviarResultadoVotacion(
_snapshot(
fase: 'resultado',
resultadoActual: resultado,
mensaje: _mensajeSiguienteAccion(estado, resultado, l10n),
).toJson(),
);
}
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: FondoFarolero(
intenso: true,
child: Center(child: Text(l10n.errorNoGame)),
),
);
}
// Solo bloquean los clientes que siguen conectados: un móvil caído no
// puede impedir que avance la partida.
final clientesPendientes =
nearby.estadoSala?.clientes.values
.where((cliente) => !cliente.esHost && cliente.conectado)
.map((cliente) => cliente.clientId) ??
const <String>[];
final todosListos =
_hostListo &&
clientesPendientes.every((id) => _clientesListos[id] == true);
final todosVotaron = estado.todosHanVotado();
return GuardiaSalidaPartida(
contexto: ContextoSalida.host,
child: Scaffold(
appBar: AppBar(
title: Text(l10n.hostGame),
automaticallyImplyLeading: false,
actions: [
IconButton(
tooltip: l10n.seeYourWord,
icon: IconoFarolero(Icons.visibility),
onPressed: partida.fase.index <= FaseJuego.verPalabra.index
? null
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: _jugadoresHostControlados(
partida,
nearby,
),
pistaImpostor: partida.config.pistaImpostor
? partida.pistaImpostor
: null,
),
),
IconButton(
tooltip: l10n.notesTitle,
icon: IconoFarolero(Icons.edit_note),
onPressed: partida.fase.index < FaseJuego.debate.index ||
nearby.roomId == null
? null
: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaNotasOnline(
partidaId: nearby.roomId!,
jugadores: partida.jugadoresActivos,
autoresControlados: _jugadoresHostControlados(
partida,
nearby,
),
),
),
),
),
IconButton(
tooltip: l10n.leaveGame,
icon: IconoFarolero(Icons.close),
onPressed: () async {
if (!await confirmarSalirDePartida(
context,
ContextoSalida.host,
)) {
return;
}
await nearby.desconectar();
widget.onPartidaFin();
},
),
],
),
body: FondoFarolero(
intenso: true,
respetarBarras: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildAvisoClientesDesconectados(context, nearby),
_buildFaseIndicator(context, partida.fase, l10n),
const SizedBox(height: 8),
const ArteGameplayFarolero.fase(height: 92),
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 _buildAvisoClientesDesconectados(
BuildContext context,
ServicioNearby nearby,
) {
final sala = nearby.estadoSala;
final usuariosAfectados = sala?.usuariosDeClientesDesconectados ?? const [];
if (usuariosAfectados.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorAcento.withValues(alpha: 0.16),
borderColor: TemaApp.colorAcento.withValues(alpha: 0.65),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconoFarolero(Icons.link_off, color: TemaApp.colorAcento),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context)!.disconnectedPlayersWarning,
style: Theme.of(context).textTheme.titleSmall,
),
),
],
),
const SizedBox(height: 6),
Text(
usuariosAfectados.map((usuario) => usuario.nombre).join(', '),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: SizedBox(
width: 260,
child: BotonFarolero.oscuro(
texto: AppLocalizations.of(context)!.assumeOnThisPhone,
icono: Icons.person_add_alt_1,
assetIconPath: 'assets/ui/generated/actions/action_add_player.webp',
onPressed: () async {
// Son jugadores nuevos para este móvil: sus palabras están
// sin ver y sus votos sin emitir.
final fase = context.read<EstadoJuego>().partida?.fase;
await nearby.asumirUsuariosDesconectados();
if (!mounted) return;
setState(() {
if (fase == FaseJuego.verPalabra) _hostListo = false;
});
},
),
),
),
],
),
),
);
}
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),
(FaseJuego.adivinanza, l10n.guess),
(FaseJuego.finPartida, l10n.gameOver),
];
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);
case FaseJuego.resultado:
return _buildFaseResultado(context, l10n);
case FaseJuego.adivinanza:
return _buildFaseAdivinanza(context, l10n);
case FaseJuego.finPartida:
return _buildFaseFinOnline(context, l10n);
default:
return Center(child: Text(l10n.gameOver));
}
}
SnapshotPartidaOnline _snapshot({
required String fase,
ResultadoVotacion? resultadoActual,
String? mensaje,
bool revelarFinal = false,
}) {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
final partida = estado.partida!;
return SnapshotPartidaOnline.desdePartida(
partida,
roomId: nearby.roomId,
fase: fase,
resultadoActual: resultadoActual,
mensaje: mensaje,
revelarPalabra: revelarFinal,
revelarImpostores: revelarFinal,
);
}
String _mensajeSiguienteAccion(
EstadoJuego estado,
ResultadoVotacion resultado,
AppLocalizations l10n,
) {
final partida = estado.partida;
if (partida != null && _hayFinTrasVotacion(partida)) {
return l10n.gameOver;
}
if (resultado.eraImpostor) {
return l10n.impostorCanGuess.replaceAll('\n', ' ');
}
return l10n.gameContinues;
}
bool _hayFinTrasVotacion(Partida partida) {
final impostoresVivos = partida.impostoresActivos.length;
final jugadoresVivos = partida.jugadoresNormalesActivos.length;
return impostoresVivos == 0 || impostoresVivos >= jugadoresVivos;
}
Widget _buildFaseVerPalabra(
BuildContext context,
AppLocalizations l10n,
bool todosListos,
ServicioNearby nearby,
) {
return TarjetaFaseFarolero(
icono: Icons.visibility,
assetIconPath: 'assets/ui/generated/actions/action_reveal_word.webp',
titulo: l10n.waitingPlayersSeeWord,
subtitulo: l10n.connectedPlayers,
child: Column(
children: [
_buildJugadorTile(nearby.miNombre ?? 'Host', true, _hostListo),
...nearby.jugadores.map(
(jugador) => _buildJugadorTile(
jugador.nombre,
false,
_clientesListos[_clientIdDe(jugador.endpointId) ??
jugador.endpointId] ??
false,
),
),
const SizedBox(height: 12),
BotonFarolero(
texto: l10n.seeYourWord,
icono: Icons.visibility,
assetIconPath: 'assets/ui/generated/actions/action_reveal_word.webp',
onPressed: () => _mostrarPalabraHost(context),
),
if (todosListos) ...[
const SizedBox(height: 12),
EstadoJugadorFarolero(
nombre: l10n.allSeenStartDebate,
completado: true,
icono: Icons.check_circle,
),
],
],
),
);
}
List<JugadorInicioPartida> _jugadoresHostControlados(
Partida partida,
ServicioNearby nearby,
) {
final sala = nearby.estadoSala;
if (sala == null) return const [];
return _jugadoresDeCliente(partida, sala, sala.hostClientId);
}
void _mostrarPalabraHost(BuildContext context) {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
final partida = estado.partida;
if (partida == null) return;
final jugadoresHost = _jugadoresHostControlados(partida, nearby);
if (jugadoresHost.length > 1) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente(
protegerSalida: false,
jugadores: jugadoresHost,
pistaImpostor: partida.config.pistaImpostor
? partida.pistaImpostor
: null,
onTodosVistos: () {
setState(() => _hostListo = true);
Navigator.of(context).pop();
},
),
),
);
return;
}
final hostLocal = jugadoresHost.isNotEmpty
? partida.jugadores.firstWhere((j) => j.id == jugadoresHost.first.jugadorId)
: partida.jugadores.first;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _PantallaRevelarPalabraHost(
nombre: hostLocal.nombre,
esImpostor: hostLocal.esImpostor,
palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor,
pista: partida.pistaImpostor,
companerosImpostores: estado.companerosImpostoresDe(hostLocal.id),
onVisto: () => setState(() => _hostListo = true),
),
),
);
}
Jugador? _elegirPrimerTurno() {
final partida = context.read<EstadoJuego>().partida;
if (partida == null || partida.jugadoresActivos.isEmpty) return null;
if (_primerTurnoId != null) {
return partida.jugadoresActivos.firstWhere(
(j) => j.id == _primerTurnoId,
orElse: () => partida.jugadoresActivos.first,
);
}
final elegido = partida.jugadoresActivos[
Random.secure().nextInt(partida.jugadoresActivos.length)];
_primerTurnoId = elegido.id;
_primerTurnoNombre = elegido.nombre;
return elegido;
}
Widget _buildFaseDebate(
BuildContext context,
AppLocalizations l10n,
ServicioNearby nearby,
) {
final estado = context.read<EstadoJuego>();
final tiempo = estado.partida?.config.tiempoDebateSegundos;
return TarjetaFaseFarolero(
icono: Icons.forum,
assetIconPath: 'assets/ui/generated/actions/action_rules_book.webp',
titulo: l10n.debate,
subtitulo: l10n.debateInstructions,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (tiempo != null) ...[
TemporizadorFarolero(
etiqueta: _segundosRestantes == 0
? l10n.timeUp
: l10n.timeRemaining,
tiempo: _formatearTiempo(_segundosRestantes),
agotado: _segundosRestantes == 0,
),
const SizedBox(height: 16),
],
_buildPrimerTurno(context),
const SizedBox(height: 16),
Text(l10n.activePlayers, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
_buildJugadorTile(nearby.miNombre ?? 'Host', true, true),
...nearby.jugadores.map(
(jugador) => _buildJugadorTile(jugador.nombre, false, true),
),
],
),
);
}
Widget _buildPrimerTurno(BuildContext context) {
final primero = _elegirPrimerTurno();
final nombre = _primerTurnoNombre ?? primero?.nombre;
if (nombre == null) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorNaranja.withValues(alpha: 0.16),
borderColor: TemaApp.colorNaranja.withValues(alpha: 0.7),
),
child: Row(
children: [
IconoFarolero(Icons.record_voice_over, color: TemaApp.colorNaranja),
const SizedBox(width: 12),
Expanded(
child: Text(
AppLocalizations.of(context)!.firstTurnInstruction(nombre),
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
);
}
Widget _buildFaseVotacion(
BuildContext context,
AppLocalizations l10n,
bool todosVotaron,
ServicioNearby nearby,
) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida!;
final totalVotos = partida.jugadoresActivos.length;
final votosEmitidos = estado.votos.length;
final progreso = totalVotos == 0 ? 0.0 : votosEmitidos / totalVotos;
return TarjetaFaseFarolero(
icono: Icons.how_to_vote,
assetIconPath: 'assets/ui/generated/actions/action_vote_mask.webp',
titulo: l10n.voting,
subtitulo: l10n.votesProgress(votosEmitidos, totalVotos),
color: TemaApp.colorAcento,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progreso.clamp(0.0, 1.0).toDouble(),
minHeight: 14,
backgroundColor: Colors.black.withValues(alpha: 0.35),
valueColor: const AlwaysStoppedAnimation(TemaApp.colorAcento),
),
),
const SizedBox(height: 16),
if (!_hostYaVoto(context))
BotonFarolero.secundario(
texto: l10n.votar,
icono: Icons.how_to_vote,
onPressed: () => _abrirVotacionHost(context),
),
if (!_hostYaVoto(context)) const SizedBox(height: 16),
...partida.jugadoresActivos.map((jugador) {
final haVotado = estado.votos.containsKey(jugador.id);
return _buildJugadorTile(jugador.nombre, false, haVotado);
}),
if (todosVotaron)
EstadoJugadorFarolero(
nombre: l10n.allVoted,
completado: true,
icono: Icons.check_circle,
),
],
),
);
}
Widget _buildFaseResultado(BuildContext context, AppLocalizations l10n) {
final partida = context.watch<EstadoJuego>().partida;
final resultado = partida?.historialVotaciones.isNotEmpty == true
? partida!.historialVotaciones.last
: null;
if (partida == null || resultado == null) {
return Center(child: Text(l10n.noResult));
}
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 8),
child: ResultadoRondaFarolero(
resultado: resultado,
jugadores: partida.jugadores,
),
);
}
Widget _buildFaseAdivinanza(BuildContext context, AppLocalizations l10n) {
final partida = context.watch<EstadoJuego>().partida;
final ultimo = partida?.historialVotaciones.isNotEmpty == true
? partida!.historialVotaciones.last
: null;
return TarjetaFaseFarolero(
icono: Icons.psychology,
assetIconPath: 'assets/ui/generated/actions/action_impostor_mask.webp',
titulo: l10n.impostorGuessTitle,
subtitulo: ultimo == null
? l10n.impostorCanGuess
: '${ultimo.eliminadoNombre}: ${l10n.impostorCanGuess}',
color: TemaApp.colorNaranja,
child: const ArteGameplayFarolero.resultado(height: 132),
);
}
Widget _buildFaseFinOnline(BuildContext context, AppLocalizations l10n) {
final partida = context.watch<EstadoJuego>().partida;
if (partida == null) return Center(child: Text(l10n.noResult));
final ganaronJugadores = partida.ganador == 'jugadores';
final color = ganaronJugadores ? TemaApp.colorVerde : TemaApp.colorAcento;
final impostores = partida.jugadores.where((j) => j.esImpostor).toList();
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
children: [
HeroFinalPartidaFarolero(
encabezado: l10n.gameOver,
titulo: ganaronJugadores ? l10n.playersWin : l10n.impostorsWin,
icono: ganaronJugadores ? Icons.emoji_events : Icons.theater_comedy,
color: color,
),
const SizedBox(height: 12),
if (_progresoGamificacion == null)
const TarjetaRecompensaCargandoPremium()
else
TarjetaProgresoGamificacionPremium(
progreso: _progresoGamificacion!,
),
const SizedBox(height: 18),
TarjetaSecretoPremium(
palabra: partida.palabraSecreta,
categoria: BancoPalabras.nombreBonitoCategoria(
partida.categoriaReal,
l10n,
),
),
const SizedBox(height: 18),
TarjetaImpostoresPremium(
titulo: impostores.length == 1
? l10n.theImpostorWas
: l10n.theImpostorsWere,
impostores: impostores,
),
const SizedBox(height: 18),
if (partida.historialVotaciones.isNotEmpty)
TarjetaHistorialVotosPremium(
historial: partida.historialVotaciones,
jugadores: partida.jugadores,
),
],
),
);
}
bool _hostYaVoto(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
if (sala == null || estado.partida == null) return false;
final hostIds = sala.usuariosPorCliente(sala.hostClientId).map((u) => u.id);
return hostIds.every((id) => estado.votos.containsKey(id));
}
void _abrirVotacionHost(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
final partida = estado.partida;
if (sala == null || partida == null) return;
final jugadoresHost = sala.usuariosPorCliente(sala.hostClientId)
.where((usuario) => partida.jugadoresActivos.any((j) => j.id == usuario.id))
.map(
(usuario) => JugadorInicioPartida(
jugadorId: usuario.id,
nombre: usuario.nombre,
esImpostor: partida.jugadores.firstWhere((j) => j.id == usuario.id).esImpostor,
palabra: partida.jugadores.firstWhere((j) => j.id == usuario.id).palabra,
),
)
.toList();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaVotacionCliente(
protegerSalida: false,
jugadores: partida.jugadoresActivos,
jugadoresControlados: jugadoresHost,
partidaId: context.read<ServicioNearby>().roomId,
pistaImpostor: partida.config.pistaImpostor
? partida.pistaImpostor
: null,
onVotos: (votos) {
for (final entry in votos.entries) {
estado.registrarVoto(entry.key, entry.value);
_votosRecibidos[entry.key] = entry.value;
}
if (mounted) setState(() {});
Navigator.of(context).pop();
},
),
),
);
}
Widget _buildJugadorTile(String nombre, bool esHost, bool listo) {
return EstadoJugadorFarolero(
nombre: nombre,
destacado: esHost,
completado: listo,
icono: esHost ? Icons.phone_android : Icons.devices,
assetIconPath: esHost ? 'assets/ui/generated/actions/action_mobile_device.webp' : 'assets/ui/generated/actions/action_multidevice_signal.webp',
);
}
Widget _buildBotonAccion(
BuildContext context,
FaseJuego fase,
AppLocalizations l10n,
bool todosListos,
bool todosVotaron,
) {
switch (fase) {
case FaseJuego.verPalabra:
return BotonFarolero(
texto: todosListos ? l10n.allSeenStartDebate : l10n.waitingPlayersSeeWord,
icono: Icons.forum,
assetIconPath: 'assets/ui/generated/actions/action_rules_book.webp',
onPressed: todosListos ? () => _avanzarAFase(FaseJuego.debate) : null,
);
case FaseJuego.debate:
return BotonFarolero.secundario(
texto: l10n.goToVoting,
icono: Icons.how_to_vote,
assetIconPath: 'assets/ui/generated/actions/action_vote_mask.webp',
onPressed: () => _avanzarAFase(FaseJuego.votacion),
);
case FaseJuego.votacion:
return BotonFarolero(
texto: todosVotaron ? l10n.revealResult : l10n.waitingVoting,
icono: Icons.visibility,
assetIconPath: 'assets/ui/generated/actions/action_result_trophy.webp',
onPressed: todosVotaron ? () => _avanzarAFase(FaseJuego.resultado) : null,
);
case FaseJuego.resultado:
return _buildAccionesResultado(context, l10n);
case FaseJuego.adivinanza:
return _buildAccionesAdivinanza(context, l10n);
case FaseJuego.finPartida:
return BotonFarolero.oscuro(
texto: l10n.mainMenu,
icono: Icons.home,
onPressed: () async {
final nearby = context.read<ServicioNearby>();
await nearby.desconectar();
widget.onPartidaFin();
},
);
default:
return const SizedBox.shrink();
}
}
Widget _buildAccionesResultado(BuildContext context, AppLocalizations l10n) {
final estado = context.read<EstadoJuego>();
final partida = estado.partida;
final resultado = partida?.historialVotaciones.isNotEmpty == true
? partida!.historialVotaciones.last
: null;
if (partida == null || resultado == null) return const SizedBox.shrink();
if (_hayFinTrasVotacion(partida)) {
return BotonFarolero(
texto: l10n.seeEndResult,
icono: Icons.emoji_events,
assetIconPath: 'assets/ui/generated/actions/action_result_trophy.webp',
onPressed: () => _finalizarPartidaOnline(context),
);
}
if (resultado.eraImpostor) {
return Column(
children: [
BotonFarolero.oscuro(
texto: l10n.impostorGuessWord,
icono: Icons.psychology,
assetIconPath: 'assets/ui/generated/actions/action_impostor_mask.webp',
onPressed: () => _iniciarAdivinanzaOnline(context),
),
const SizedBox(height: 12),
BotonFarolero(
texto: l10n.nextRound,
icono: Icons.skip_next,
onPressed: () => _siguienteRondaOnline(context),
),
],
);
}
return BotonFarolero(
texto: l10n.nextRound,
icono: Icons.skip_next,
onPressed: () => _siguienteRondaOnline(context),
);
}
Widget _buildAccionesAdivinanza(BuildContext context, AppLocalizations l10n) {
return Column(
children: [
BotonFarolero(
texto: l10n.guess,
icono: Icons.check_circle,
assetIconPath: 'assets/ui/generated/actions/action_impostor_mask.webp',
onPressed: () => _resolverAdivinanzaOnline(context),
),
const SizedBox(height: 12),
BotonFarolero.oscuro(
texto: l10n.dontGuess,
icono: Icons.skip_next,
onPressed: () => _siguienteRondaOnline(context),
),
],
);
}
Future<void> _finalizarPartidaOnline(BuildContext context) async {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
estado.comprobarFinPartida();
final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true)
.toJson();
await _guardarHistorialOnlineHost(context);
await nearby.enviarFinPartida(snapshotFinal);
if (mounted) setState(() {});
}
Future<void> _guardarHistorialOnlineHost(BuildContext context) async {
if (_partidaOnlineGuardada) return;
final partida = context.read<EstadoJuego>().partida;
if (partida?.ganador == null) return;
_partidaOnlineGuardada = true;
final historial = context.read<ServicioHistorialPartidas>();
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>();
await historial.guardarPartida(partida!);
final jugadoresHost = _jugadoresHostControlados(
partida,
nearby,
);
final ganaronImpostores = partida.ganador == 'impostores';
final victoria = jugadoresHost.isEmpty
? partida.ganador == 'jugadores'
: jugadoresHost.any(
(jugador) =>
jugador.esImpostor ? ganaronImpostores : !ganaronImpostores,
);
final progreso = await perfil.registrarPartidaCompletada(
victoria: victoria,
comoImpostor: jugadoresHost.any((j) => j.esImpostor),
victoriaComoImpostor:
ganaronImpostores && jugadoresHost.any((j) => j.esImpostor),
);
if (mounted) setState(() => _progresoGamificacion = progreso);
}
Future<void> _iniciarAdivinanzaOnline(BuildContext context) async {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
estado.iniciarAdivinanza();
await nearby.enviarCambioFase(
'adivinanza',
_snapshot(
fase: 'adivinanza',
mensaje: AppLocalizations.of(context)!.impostorCanGuess,
).toJson(),
);
if (mounted) setState(() {});
}
Future<void> _resolverAdivinanzaOnline(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final intento = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.impostorGuessTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: l10n.guessWordHint),
onSubmitted: (value) => Navigator.pop(ctx, value),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: Text(l10n.accept),
),
],
),
);
controller.dispose();
if (!context.mounted) return;
if (intento == null || intento.trim().isEmpty) {
await _siguienteRondaOnline(context);
return;
}
final estado = context.read<EstadoJuego>();
final acierto = estado.intentarAdivinar(intento);
if (acierto) {
final nearby = context.read<ServicioNearby>();
final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true)
.toJson();
await _guardarHistorialOnlineHost(context);
await nearby.enviarFinPartida(snapshotFinal);
if (mounted) setState(() {});
return;
}
await _siguienteRondaOnline(context);
}
Future<void> _siguienteRondaOnline(BuildContext context) async {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
estado.siguienteRonda();
_primerTurnoId = null;
_primerTurnoNombre = null;
final primero = _elegirPrimerTurno();
await nearby.enviarCambioFase('debate', {
..._snapshot(fase: 'debate').toJson(),
if (primero != null) ...{
'primerTurnoId': primero.id,
'primerTurnoNombre': primero.nombre,
},
if (estado.partida?.config.tiempoDebateSegundos != null)
'tiempoDebateSegundos': estado.partida!.config.tiempoDebateSegundos,
});
_timer?.cancel();
_iniciarTemporizador();
if (mounted) setState(() {});
}
}
class _PantallaRevelarPalabraHost extends StatefulWidget {
final String nombre;
final bool esImpostor;
final String palabra;
final bool pistaActiva;
final String pista;
final List<String>? companerosImpostores;
final VoidCallback onVisto;
const _PantallaRevelarPalabraHost({
required this.nombre,
required this.esImpostor,
required this.palabra,
required this.pistaActiva,
required this.pista,
required this.companerosImpostores,
required this.onVisto,
});
@override
State<_PantallaRevelarPalabraHost> createState() =>
_PantallaRevelarPalabraHostState();
}
class _PantallaRevelarPalabraHostState
extends State<_PantallaRevelarPalabraHost> {
bool _manteniendo = false;
bool _haRevelado = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(widget.nombre)),
body: FondoFarolero(
intenso: true,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.nombre,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorVerde.withValues(alpha: 0.3))
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde)
: Colors.transparent,
width: 2,
),
),
child: _manteniendo
? Column(
children: [
IconoFarolero(
widget.esImpostor
? Icons.theater_comedy
: Icons.key,
color: widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde,
size: 48,
),
const SizedBox(height: 16),
Text(
widget.esImpostor
? l10n.youAreImpostor
: l10n.yourWordIs,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
color: widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde,
),
),
if (!widget.esImpostor) ...[
const SizedBox(height: 12),
TarjetaPalabraFarolero(palabra: widget.palabra),
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
PistaImpostorFarolero(pista: widget.pista),
],
if (widget.esImpostor &&
widget.companerosImpostores != null) ...[
const SizedBox(height: 12),
CompanerosImpostorFarolero(
nombres: widget.companerosImpostores!,
),
],
],
)
: Column(
children: [
IconoFarolero(
Icons.lock,
color: TemaApp.colorDorado,
size: 48,
),
const SizedBox(height: 16),
Text(
l10n.holdToSeeWord,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.makeSureNoOneLooks,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 24),
GestureDetector(
onLongPressStart: (_) => setState(() {
_manteniendo = true;
_haRevelado = true;
}),
onLongPressEnd: (_) => setState(() => _manteniendo = false),
child: Container(
width: double.infinity,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _manteniendo
? [TemaApp.colorNaranja, TemaApp.colorAcento]
: [TemaApp.colorAcento, TemaApp.colorAcento],
),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
_manteniendo ? l10n.showingWord : l10n.holdToSee,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 16),
BotonFarolero(
texto: _haRevelado ? l10n.iveSeenIt : l10n.tapToSee,
icono: Icons.check,
onPressed: _haRevelado
? () {
widget.onVisto();
Navigator.of(context).pop();
}
: null,
),
],
),
),
),
),
);
}
}