diff --git a/lib/modelos/snapshot_partida_online.dart b/lib/modelos/snapshot_partida_online.dart new file mode 100644 index 0000000..0f7e6a4 --- /dev/null +++ b/lib/modelos/snapshot_partida_online.dart @@ -0,0 +1,132 @@ +import 'jugador.dart'; +import 'partida.dart'; + +class SnapshotPartidaOnline { + final String? roomId; + final String fase; + final int ronda; + final String categoria; + final String? palabraSecreta; + final String? ganador; + final List jugadores; + final ResultadoVotacion? resultadoActual; + final List historialVotaciones; + final List impostores; + final String? mensaje; + + const SnapshotPartidaOnline({ + required this.roomId, + required this.fase, + required this.ronda, + required this.categoria, + required this.jugadores, + this.palabraSecreta, + this.ganador, + this.resultadoActual, + this.historialVotaciones = const [], + this.impostores = const [], + this.mensaje, + }); + + factory SnapshotPartidaOnline.desdePartida( + Partida partida, { + String? roomId, + String? fase, + ResultadoVotacion? resultadoActual, + String? mensaje, + bool revelarImpostores = false, + bool revelarPalabra = false, + }) { + return SnapshotPartidaOnline( + roomId: roomId, + fase: fase ?? partida.fase.name, + ronda: partida.rondaActual, + categoria: partida.categoriaReal, + palabraSecreta: revelarPalabra ? partida.palabraSecreta : null, + ganador: partida.ganador, + jugadores: partida.jugadores, + resultadoActual: resultadoActual ?? + (partida.historialVotaciones.isEmpty + ? null + : partida.historialVotaciones.last), + historialVotaciones: partida.historialVotaciones, + impostores: revelarImpostores + ? partida.jugadores + .where((jugador) => jugador.esImpostor) + .map((jugador) => jugador.nombre) + .toList() + : const [], + mensaje: mensaje, + ); + } + + Map toJson() => { + 'roomId': roomId, + 'fase': fase, + 'round': ronda, + 'categoria': categoria, + if (palabraSecreta != null) 'palabraSecreta': palabraSecreta, + if (ganador != null) 'ganador': ganador, + 'jugadoresTodos': jugadores.map(_jugadorToJson).toList(), + if (resultadoActual != null) + 'resultadoActual': _resultadoToJson(resultadoActual!), + 'historialVotaciones': + historialVotaciones.map(_resultadoToJson).toList(), + if (impostores.isNotEmpty) 'impostores': impostores, + if (mensaje != null) 'mensaje': mensaje, + }; + + factory SnapshotPartidaOnline.fromJson(Map json) { + final jugadoresData = json['jugadoresTodos'] as List? ?? const []; + final historialData = + json['historialVotaciones'] as List? ?? const []; + final resultadoData = json['resultadoActual'] as Map?; + + return SnapshotPartidaOnline( + roomId: json['roomId'] as String?, + fase: json['fase'] as String? ?? '', + ronda: (json['round'] as num?)?.toInt() ?? 1, + categoria: json['categoria'] as String? ?? '', + palabraSecreta: json['palabraSecreta'] as String?, + ganador: json['ganador'] as String?, + jugadores: jugadoresData + .map((data) => Jugador.fromJson(data as Map)) + .toList(), + resultadoActual: + resultadoData == null ? null : _resultadoFromJson(resultadoData), + historialVotaciones: historialData + .map((data) => _resultadoFromJson(data as Map)) + .toList(), + impostores: (json['impostores'] as List? ?? const []) + .map((nombre) => nombre.toString()) + .toList(), + mensaje: json['mensaje'] as String?, + ); + } + + static Map _jugadorToJson(Jugador jugador) => { + 'id': jugador.id, + 'nombre': jugador.nombre, + 'esImpostor': jugador.esImpostor, + 'eliminado': jugador.eliminado, + }; + + static Map _resultadoToJson(ResultadoVotacion resultado) => { + 'eliminadoId': resultado.eliminadoId, + 'eliminadoNombre': resultado.eliminadoNombre, + 'eraImpostor': resultado.eraImpostor, + 'votos': resultado.votos, + }; + + static ResultadoVotacion _resultadoFromJson(Map json) { + final votos = (json['votos'] as Map? ?? const {}).map( + (key, value) => MapEntry(key.toString(), value.toString()), + ); + return ResultadoVotacion( + eliminadoId: json['eliminadoId'] as String? ?? '', + eliminadoNombre: json['eliminadoNombre'] as String? ?? '', + eraImpostor: json['eraImpostor'] as bool? ?? false, + votos: votos, + ); + } +} diff --git a/lib/pantallas/pantalla_debate_cliente.dart b/lib/pantallas/pantalla_debate_cliente.dart index 9400f4f..74e5807 100644 --- a/lib/pantallas/pantalla_debate_cliente.dart +++ b/lib/pantallas/pantalla_debate_cliente.dart @@ -5,7 +5,10 @@ import 'package:farolero/modelos/inicio_partida_multijugador.dart'; import 'package:farolero/modelos/jugador.dart'; import 'package:farolero/pantallas/pantalla_notas_online.dart'; import 'package:farolero/pantallas/pantalla_revision_palabra.dart'; +import 'package:farolero/pantallas/pantalla_votacion_cliente.dart'; +import 'package:farolero/servicios/servicio_nearby.dart'; import 'package:farolero/tema/tema_app.dart'; +import 'package:provider/provider.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í. @@ -37,10 +40,36 @@ class _PantallaDebateClienteState extends State { Timer? _timer; int _segundosRestantes = 0; bool _votacionSolicitada = false; + OnMensajeCallback? _listener; + ServicioNearby? _nearby; @override void initState() { super.initState(); + _listener = (endpointId, mensaje) { + if (!mounted || mensaje.tipo != TipoMensaje.fase) return; + final fase = mensaje.datos['fase'] as String?; + if (fase == 'votacion') { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => PantallaVotacionCliente( + jugadores: widget.jugadores, + jugadoresControlados: widget.jugadoresControlados, + partidaId: widget.partidaId, + pistaCategoria: widget.pistaCategoria, + onVotos: _enviarVotos, + ), + ), + ); + } + }; + WidgetsBinding.instance.addPostFrameCallback((_) { + final listener = _listener; + if (listener != null && mounted) { + _nearby = context.read(); + _nearby!.onMensaje(listener); + } + }); if (widget.tiempoDebateSegundos != null) { _segundosRestantes = widget.tiempoDebateSegundos!; _timer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -56,9 +85,31 @@ class _PantallaDebateClienteState extends State { @override void dispose() { _timer?.cancel(); + final listener = _listener; + if (listener != null) { + _nearby?.removeMensajeListener(listener); + } super.dispose(); } + void _enviarVotos(Map votos) { + final nearby = context.read(); + if (nearby.hostEndpointId == null) return; + for (final entry in votos.entries) { + nearby.enviarMensaje( + nearby.hostEndpointId!, + MensajeP2P( + tipo: TipoMensaje.voto, + datos: { + 'votanteId': entry.key, + 'votadoId': entry.value, + 'votoporId': entry.value, + }, + ), + ); + } + } + String _formatearTiempo(int segundos) { final min = segundos ~/ 60; final seg = segundos % 60; diff --git a/lib/pantallas/pantalla_fin_partida_online.dart b/lib/pantallas/pantalla_fin_partida_online.dart new file mode 100644 index 0000000..a02bdcd --- /dev/null +++ b/lib/pantallas/pantalla_fin_partida_online.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:farolero/l10n/generated/app_localizations.dart'; +import 'package:provider/provider.dart'; +import '../modelos/inicio_partida_multijugador.dart'; +import '../modelos/palabra.dart'; +import '../modelos/snapshot_partida_online.dart'; +import '../servicios/servicio_nearby.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_notas_online.dart'; +import 'pantalla_principal.dart'; +import 'pantalla_revision_palabra.dart'; + +class PantallaFinPartidaOnline extends StatelessWidget { + final SnapshotPartidaOnline snapshot; + final List jugadoresControlados; + final String? pistaCategoria; + + const PantallaFinPartidaOnline({ + super.key, + required this.snapshot, + required this.jugadoresControlados, + this.pistaCategoria, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final ganaronJugadores = snapshot.ganador == 'jugadores'; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.gameOver), + automaticallyImplyLeading: false, + actions: [ + IconButton( + tooltip: l10n.seeYourWord, + icon: const Icon(Icons.visibility), + onPressed: jugadoresControlados.isEmpty + ? null + : () => mostrarRevisionPalabraOnline( + context: context, + jugadoresControlados: jugadoresControlados, + pistaCategoria: pistaCategoria, + ), + ), + IconButton( + tooltip: l10n.notesTitle, + icon: const Icon(Icons.edit_note), + onPressed: snapshot.roomId == null || jugadoresControlados.isEmpty + ? null + : () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaNotasOnline( + partidaId: snapshot.roomId!, + jugadores: snapshot.jugadores, + autoresControlados: jugadoresControlados, + ), + ), + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: ganaronJugadores + ? [ + TemaApp.colorVerde.withValues(alpha: 0.3), + TemaApp.colorVerde.withValues(alpha: 0.1), + ] + : [ + TemaApp.colorAcento.withValues(alpha: 0.3), + TemaApp.colorAcento.withValues(alpha: 0.1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: ganaronJugadores + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + ), + child: Column( + children: [ + Text( + ganaronJugadores ? '🎉' : '🎭', + style: const TextStyle(fontSize: 64), + ), + const SizedBox(height: 16), + Text( + ganaronJugadores ? l10n.playersWin : l10n.impostorsWin, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: ganaronJugadores + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Text( + l10n.theSecretWordWas, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + snapshot.palabraSecreta ?? '?', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: TemaApp.colorNaranja, + fontSize: 32, + ), + ), + const SizedBox(height: 4), + Text( + l10n.categoryLabel( + BancoPalabras.nombreBonitoCategoria( + snapshot.categoria, + l10n, + ), + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Text( + snapshot.impostores.length == 1 + ? l10n.theImpostorWas + : l10n.theImpostorsWere, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ...snapshot.impostores.map( + (nombre) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + '🎭 $nombre', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: TemaApp.colorAcento, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + if (snapshot.historialVotaciones.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.votingHistory, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + ...snapshot.historialVotaciones.asMap().entries.map( + (entrada) { + final ronda = entrada.key + 1; + final resultado = entrada.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + l10n.roundElimination( + ronda, + resultado.eliminadoNombre, + ), + style: TextStyle( + fontWeight: FontWeight.bold, + color: resultado.eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + ), + ); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton.icon( + onPressed: () async { + await context.read().desconectar(); + if (!context.mounted) return; + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => const PantallaPrincipal(), + ), + (route) => false, + ); + }, + icon: const Icon(Icons.home), + label: Text(l10n.mainMenu), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_gestor_host.dart b/lib/pantallas/pantalla_gestor_host.dart index cdcca96..93e5bdf 100644 --- a/lib/pantallas/pantalla_gestor_host.dart +++ b/lib/pantallas/pantalla_gestor_host.dart @@ -7,6 +7,7 @@ import '../estado/estado_juego.dart'; import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/jugador.dart'; import '../modelos/partida.dart'; +import '../modelos/snapshot_partida_online.dart'; import '../servicios/servicio_nearby.dart'; import '../tema/componentes_farolero.dart'; import '../tema/tema_app.dart'; @@ -94,6 +95,7 @@ class _PantallaGestorHostState extends State { estado.iniciarDebate(); final primero = _elegirPrimerTurno(); nearby.enviarCambioFase('debate', { + ..._snapshot(fase: 'debate').toJson(), if (primero != null) ...{ 'primerTurnoId': primero.id, 'primerTurnoNombre': primero.nombre, @@ -105,18 +107,19 @@ class _PantallaGestorHostState extends State { break; case FaseJuego.votacion: estado.iniciarVotacion(); - nearby.enviarCambioFase('votacion'); + nearby.enviarCambioFase('votacion', _snapshot(fase: 'votacion').toJson()); _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, - }); + nearby.enviarResultadoVotacion( + _snapshot( + fase: 'resultado', + resultadoActual: resultado, + mensaje: _mensajeSiguienteAccion(estado, resultado), + ).toJson(), + ); } break; default: @@ -233,6 +236,8 @@ class _PantallaGestorHostState extends State { (FaseJuego.debate, l10n.debate), (FaseJuego.votacion, l10n.voting), (FaseJuego.resultado, l10n.result), + (FaseJuego.adivinanza, l10n.guess), + (FaseJuego.finPartida, l10n.gameOver), ]; return SingleChildScrollView( @@ -278,11 +283,55 @@ class _PantallaGestorHostState extends State { 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 const Center(child: Text('Fin de la partida')); } } + SnapshotPartidaOnline _snapshot({ + required String fase, + ResultadoVotacion? resultadoActual, + String? mensaje, + bool revelarFinal = false, + }) { + final estado = context.read(); + final nearby = context.read(); + 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, + ) { + final partida = estado.partida; + if (partida != null && _hayFinTrasVotacion(partida)) { + return 'La partida ha terminado.'; + } + if (resultado.eraImpostor) { + return 'El impostor eliminado puede intentar adivinar la palabra.'; + } + return 'La partida continúa en la siguiente ronda.'; + } + + 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, @@ -775,6 +824,67 @@ class _PantallaGestorHostState extends State { ); } + Widget _buildFaseAdivinanza(BuildContext context, AppLocalizations l10n) { + final partida = context.watch().partida; + final ultimo = partida?.historialVotaciones.isNotEmpty == true + ? partida!.historialVotaciones.last + : null; + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.psychology, size: 56, color: TemaApp.colorNaranja), + const SizedBox(height: 16), + Text( + l10n.impostorGuessTitle, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + ultimo == null + ? l10n.impostorCanGuess + : '${ultimo.eliminadoNombre}: ${l10n.impostorCanGuess}', + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildFaseFinOnline(BuildContext context, AppLocalizations l10n) { + final partida = context.watch().partida; + final ganaronJugadores = partida?.ganador == 'jugadores'; + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + ganaronJugadores ? '🎉' : '🎭', + style: const TextStyle(fontSize: 64), + ), + const SizedBox(height: 16), + Text( + ganaronJugadores ? l10n.playersWin : l10n.impostorsWin, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 12), + Text( + partida == null ? '' : l10n.theWordWas(partida.palabraSecreta), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + bool _hostYaVoto(BuildContext context) { final estado = context.read(); final sala = context.read().estadoSala; @@ -895,10 +1005,200 @@ class _PantallaGestorHostState extends State { label: Text(todosVotaron ? l10n.revealResult : l10n.waitingVoting), ), ); + case FaseJuego.resultado: + return _buildAccionesResultado(context, l10n); + case FaseJuego.adivinanza: + return _buildAccionesAdivinanza(context, l10n); + case FaseJuego.finPartida: + return SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton.icon( + onPressed: () async { + final nearby = context.read(); + await nearby.desconectar(); + widget.onPartidaFin(); + }, + icon: const Icon(Icons.home), + label: Text(l10n.mainMenu), + ), + ); default: return const SizedBox.shrink(); } } + + Widget _buildAccionesResultado(BuildContext context, AppLocalizations l10n) { + final estado = context.read(); + 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 SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () => _finalizarPartidaOnline(context), + icon: const Icon(Icons.emoji_events), + label: Text(l10n.seeEndResult), + ), + ); + } + + if (resultado.eraImpostor) { + return Column( + children: [ + SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton.icon( + onPressed: () => _iniciarAdivinanzaOnline(context), + icon: const Icon(Icons.psychology), + label: Text(l10n.impostorGuessWord), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () => _siguienteRondaOnline(context), + icon: const Icon(Icons.skip_next), + label: Text(l10n.nextRound), + ), + ), + ], + ); + } + + return SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () => _siguienteRondaOnline(context), + icon: const Icon(Icons.skip_next), + label: Text(l10n.nextRound), + ), + ); + } + + Widget _buildAccionesAdivinanza(BuildContext context, AppLocalizations l10n) { + return Column( + children: [ + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () => _resolverAdivinanzaOnline(context), + icon: const Icon(Icons.check_circle), + label: Text(l10n.guess), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton.icon( + onPressed: () => _siguienteRondaOnline(context), + icon: const Icon(Icons.skip_next), + label: Text(l10n.dontGuess), + ), + ), + ], + ); + } + + Future _finalizarPartidaOnline(BuildContext context) async { + final estado = context.read(); + final nearby = context.read(); + estado.comprobarFinPartida(); + await nearby.enviarFinPartida( + _snapshot(fase: 'finPartida', revelarFinal: true).toJson(), + ); + if (mounted) setState(() {}); + } + + Future _iniciarAdivinanzaOnline(BuildContext context) async { + final estado = context.read(); + final nearby = context.read(); + estado.iniciarAdivinanza(); + await nearby.enviarCambioFase( + 'adivinanza', + _snapshot( + fase: 'adivinanza', + mensaje: AppLocalizations.of(context)!.impostorCanGuess, + ).toJson(), + ); + if (mounted) setState(() {}); + } + + Future _resolverAdivinanzaOnline(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + final intento = await showDialog( + 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(); + final acierto = estado.intentarAdivinar(intento); + if (acierto) { + final nearby = context.read(); + await nearby.enviarFinPartida( + _snapshot(fase: 'finPartida', revelarFinal: true).toJson(), + ); + if (mounted) setState(() {}); + return; + } + await _siguienteRondaOnline(context); + } + + Future _siguienteRondaOnline(BuildContext context) async { + final estado = context.read(); + final nearby = context.read(); + 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 { diff --git a/lib/pantallas/pantalla_resultado_online.dart b/lib/pantallas/pantalla_resultado_online.dart new file mode 100644 index 0000000..63df66e --- /dev/null +++ b/lib/pantallas/pantalla_resultado_online.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:farolero/l10n/generated/app_localizations.dart'; +import '../modelos/inicio_partida_multijugador.dart'; +import '../modelos/jugador.dart'; +import '../modelos/partida.dart'; +import '../modelos/snapshot_partida_online.dart'; +import '../servicios/servicio_nearby.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_debate_cliente.dart'; +import 'pantalla_fin_partida_online.dart'; +import 'pantalla_notas_online.dart'; +import 'pantalla_revision_palabra.dart'; +import 'pantalla_votacion_cliente.dart'; +import 'package:provider/provider.dart'; + +class PantallaResultadoOnline extends StatefulWidget { + final SnapshotPartidaOnline snapshot; + final List jugadoresControlados; + final String? pistaCategoria; + + const PantallaResultadoOnline({ + super.key, + required this.snapshot, + required this.jugadoresControlados, + this.pistaCategoria, + }); + + @override + State createState() => _PantallaResultadoOnlineState(); +} + +class _PantallaResultadoOnlineState extends State { + OnMensajeCallback? _listener; + ServicioNearby? _nearby; + late SnapshotPartidaOnline _snapshot; + + @override + void initState() { + super.initState(); + _snapshot = widget.snapshot; + _listener = (endpointId, mensaje) { + if (!mounted) return; + if (mensaje.tipo == TipoMensaje.partidaFin) { + _abrirFin(mensaje.datos); + return; + } + if (mensaje.tipo == TipoMensaje.votacionResultado) { + setState(() => _snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos)); + return; + } + if (mensaje.tipo != TipoMensaje.fase) return; + final fase = mensaje.datos['fase'] as String?; + if (fase == 'debate') { + _abrirDebate(mensaje.datos); + } else if (fase == 'votacion') { + _abrirVotacion(mensaje.datos); + } else if (fase == 'adivinanza' || fase == 'resultado') { + setState(() => _snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos)); + } else if (fase == 'finPartida') { + _abrirFin(mensaje.datos); + } + }; + WidgetsBinding.instance.addPostFrameCallback((_) { + final listener = _listener; + if (listener != null && mounted) { + _nearby = context.read(); + _nearby!.onMensaje(listener); + } + }); + } + + @override + void dispose() { + final listener = _listener; + if (listener != null) { + _nearby?.removeMensajeListener(listener); + } + super.dispose(); + } + + void _abrirDebate(Map datos) { + final snapshot = SnapshotPartidaOnline.fromJson(datos); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => PantallaDebateCliente( + tiempoDebateSegundos: datos['tiempoDebateSegundos'] as int?, + primerTurnoNombre: datos['primerTurnoNombre'] as String?, + partidaId: snapshot.roomId, + pistaCategoria: widget.pistaCategoria, + jugadores: snapshot.jugadores, + jugadoresControlados: widget.jugadoresControlados, + onSolicitarVotacion: _solicitarVotacion, + ), + ), + ); + } + + void _abrirVotacion(Map datos) { + final snapshot = SnapshotPartidaOnline.fromJson(datos); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => PantallaVotacionCliente( + jugadores: snapshot.jugadores, + jugadoresControlados: widget.jugadoresControlados, + partidaId: snapshot.roomId, + pistaCategoria: widget.pistaCategoria, + onVotos: _enviarVotos, + ), + ), + ); + } + + void _abrirFin(Map datos) { + final snapshot = SnapshotPartidaOnline.fromJson(datos); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => PantallaFinPartidaOnline( + snapshot: snapshot, + jugadoresControlados: widget.jugadoresControlados, + pistaCategoria: widget.pistaCategoria, + ), + ), + ); + } + + void _solicitarVotacion() { + final nearby = context.read(); + if (nearby.hostEndpointId == null) return; + nearby.enviarMensaje( + nearby.hostEndpointId!, + MensajeP2P( + tipo: TipoMensaje.ping, + datos: {'solicitoVotacion': true}, + ), + ); + } + + void _enviarVotos(Map votos) { + final nearby = context.read(); + if (nearby.hostEndpointId == null) return; + for (final entry in votos.entries) { + nearby.enviarMensaje( + nearby.hostEndpointId!, + MensajeP2P( + tipo: TipoMensaje.voto, + datos: { + 'votanteId': entry.key, + 'votadoId': entry.value, + 'votoporId': entry.value, + }, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final resultado = _snapshot.resultadoActual; + + return Scaffold( + backgroundColor: TemaApp.colorFondo, + appBar: AppBar( + title: Text(_snapshot.fase == 'adivinanza' + ? l10n.impostorGuessTitle + : l10n.result), + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + elevation: 0, + actions: _acciones(context, l10n), + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: resultado == null + ? _buildEsperaAdivinanza(context, l10n) + : _buildResultado(context, l10n, resultado), + ), + ); + } + + List _acciones(BuildContext context, AppLocalizations l10n) => [ + IconButton( + tooltip: l10n.seeYourWord, + icon: const Icon(Icons.visibility), + onPressed: widget.jugadoresControlados.isEmpty + ? null + : () => mostrarRevisionPalabraOnline( + context: context, + jugadoresControlados: widget.jugadoresControlados, + pistaCategoria: widget.pistaCategoria, + ), + ), + IconButton( + tooltip: l10n.notesTitle, + icon: const Icon(Icons.edit_note), + onPressed: _snapshot.roomId == null || widget.jugadoresControlados.isEmpty + ? null + : () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaNotasOnline( + partidaId: _snapshot.roomId!, + jugadores: _snapshot.jugadores, + autoresControlados: widget.jugadoresControlados, + ), + ), + ), + ), + ]; + + Widget _buildEsperaAdivinanza( + BuildContext context, + AppLocalizations l10n, + ) { + return Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.hourglass_top, size: 48), + const SizedBox(height: 16), + Text( + _snapshot.mensaje ?? l10n.impostorCanGuess, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text( + l10n.waitingForHost, + textAlign: TextAlign.center, + style: TextStyle(color: TemaApp.colorTextoSecundario), + ), + ], + ), + ), + ), + ); + } + + Widget _buildResultado( + BuildContext context, + AppLocalizations l10n, + ResultadoVotacion resultado, + ) { + final conteo = {}; + for (final votadoId in resultado.votos.values) { + conteo[votadoId] = (conteo[votadoId] ?? 0) + 1; + } + final maxVotos = conteo.values.isEmpty + ? 1 + : conteo.values.reduce((a, b) => a > b ? a : b); + final ranking = conteo.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final jugadores = {for (final jugador in _snapshot.jugadores) jugador.id: jugador}; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: TemaApp.decoracionPanel( + color: resultado.eraImpostor + ? TemaApp.colorVerde.withValues(alpha: 0.18) + : TemaApp.colorAcento.withValues(alpha: 0.18), + borderColor: + resultado.eraImpostor ? TemaApp.colorVerde : TemaApp.colorAcento, + ), + child: Column( + children: [ + Text( + resultado.eliminadoNombre, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + resultado.eraImpostor ? l10n.wasImpostor : l10n.wasInnocent, + style: TextStyle( + color: resultado.eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + fontWeight: FontWeight.bold, + ), + ), + if (_snapshot.mensaje != null) ...[ + const SizedBox(height: 12), + Text(_snapshot.mensaje!, textAlign: TextAlign.center), + ], + ], + ), + ), + const SizedBox(height: 20), + Text(l10n.votesThisRound, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Expanded( + child: ListView( + children: [ + ...ranking.map((entry) { + final jugador = jugadores[entry.key]; + final destacado = entry.key == resultado.eliminadoId; + final color = destacado ? TemaApp.colorAcento : TemaApp.colorNaranja; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(jugador?.nombre ?? '?')), + Text( + '${entry.value}', + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: (entry.value / maxVotos).clamp(0.0, 1.0).toDouble(), + minHeight: 10, + backgroundColor: TemaApp.colorSuperficie, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + }), + const Divider(height: 24), + ...resultado.votos.entries.map((entry) { + final votante = jugadores[entry.key]?.nombre ?? '?'; + final votado = jugadores[entry.value]?.nombre ?? '?'; + return ListTile( + dense: true, + leading: const Icon(Icons.how_to_vote), + title: Text('$votante → $votado'), + ); + }), + ], + ), + ), + ], + ); + } +} diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index b781a34..777230b 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; import '../modelos/jugador.dart'; import '../modelos/inicio_partida_multijugador.dart'; +import '../modelos/snapshot_partida_online.dart'; import '../modelos/usuario.dart'; import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_permisos.dart'; @@ -14,6 +15,8 @@ import 'pantalla_palabra_cliente.dart'; import 'pantalla_palabras_cliente.dart'; import 'pantalla_debate_cliente.dart'; import 'pantalla_votacion_cliente.dart'; +import 'pantalla_resultado_online.dart'; +import 'pantalla_fin_partida_online.dart'; /// Pantalla para unirse a una partida multidispositivo. /// Flujo: nombre → discovery automático (lista de salas) → fallback QR @@ -59,6 +62,7 @@ class _PantallaUnirseState extends State { void _registrarListenerPartida() { final nearby = context.read(); nearby.onMensaje((endpointId, mensaje) { + if (!mounted) return; if (mensaje.tipo == TipoMensaje.partidaInicio) { // El host ha iniciado la partida — nos ha enviado nuestra palabra final jugadoresData = mensaje.datos['jugadores'] as List?; @@ -112,11 +116,17 @@ class _PantallaUnirseState extends State { _navegarAPalabra(); } } else if (mensaje.tipo == TipoMensaje.fase) { - // El host cambia de fase — navegar a la pantalla correspondiente final fase = mensaje.datos['fase'] as String?; + _actualizarSnapshotSiExiste(mensaje.datos); if (mounted && fase != null) { - _navegarSegunFase(fase); + _navegarSegunFase(fase, mensaje.datos); } + } else if (mensaje.tipo == TipoMensaje.votacionResultado) { + _actualizarSnapshotSiExiste(mensaje.datos); + if (mounted) _navegarResultado(mensaje.datos); + } else if (mensaje.tipo == TipoMensaje.partidaFin) { + _actualizarSnapshotSiExiste(mensaje.datos); + if (mounted) _navegarFinPartida(mensaje.datos); } }); } @@ -166,10 +176,28 @@ class _PantallaUnirseState extends State { ); } - void _navegarSegunFase(String fase) { + void _actualizarSnapshotSiExiste(Map datos) { + final jugadoresTodosData = datos['jugadoresTodos'] as List?; + if (jugadoresTodosData == null) return; + setState(() { + _jugadores + ..clear() + ..addAll( + jugadoresTodosData.map( + (json) => Jugador.fromJson(json as Map), + ), + ); + _partidaId = (datos['roomId'] as String?) ?? + _partidaId ?? + context.read().roomId; + _pistaCategoria = (datos['categoria'] as String?) ?? _pistaCategoria; + }); + } + + void _navegarSegunFase(String fase, [Map? datos]) { switch (fase) { case 'debate': - final datosFase = context.read().datosPartida; + final datosFase = datos ?? context.read().datosPartida; Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => PantallaDebateCliente( @@ -227,9 +255,43 @@ class _PantallaUnirseState extends State { ), ); break; + case 'resultado': + case 'adivinanza': + _navegarResultado(datos ?? context.read().datosPartida); + break; + case 'finPartida': + _navegarFinPartida(datos ?? context.read().datosPartida); + break; } } + void _navegarResultado(Map? datos) { + if (datos == null) return; + final snapshot = SnapshotPartidaOnline.fromJson(datos); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => PantallaResultadoOnline( + snapshot: snapshot, + jugadoresControlados: List.unmodifiable(_jugadoresControlados), + pistaCategoria: _pistaCategoria, + ), + ), + ); + } + + void _navegarFinPartida(Map? datos) { + if (datos == null) return; + final snapshot = SnapshotPartidaOnline.fromJson(datos); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => PantallaFinPartidaOnline( + snapshot: snapshot, + jugadoresControlados: List.unmodifiable(_jugadoresControlados), + pistaCategoria: _pistaCategoria, + ), + ), + ); + } @override void dispose() { _nombreController.dispose(); diff --git a/lib/pantallas/pantalla_votacion_cliente.dart b/lib/pantallas/pantalla_votacion_cliente.dart index 0ce3820..3808724 100644 --- a/lib/pantallas/pantalla_votacion_cliente.dart +++ b/lib/pantallas/pantalla_votacion_cliente.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/modelos/inicio_partida_multijugador.dart'; import 'package:farolero/modelos/jugador.dart'; +import 'package:farolero/modelos/snapshot_partida_online.dart'; import 'package:farolero/pantallas/pantalla_notas_online.dart'; import 'package:farolero/pantallas/pantalla_revision_palabra.dart'; +import 'package:farolero/pantallas/pantalla_resultado_online.dart'; import 'package:farolero/servicios/servicio_nearby.dart'; import 'package:farolero/tema/tema_app.dart'; import 'package:provider/provider.dart'; @@ -49,7 +51,20 @@ class _PantallaVotacionClienteState extends State { super.initState(); _listener = (endpointId, mensaje) { if (mensaje.tipo != TipoMensaje.votacionResultado || !mounted) return; - setState(() => _resultado = mensaje.datos); + if (mensaje.datos.containsKey('jugadoresTodos')) { + final snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => PantallaResultadoOnline( + snapshot: snapshot, + jugadoresControlados: widget.jugadoresControlados, + pistaCategoria: widget.pistaCategoria, + ), + ), + ); + } else { + setState(() => _resultado = mensaje.datos); + } }; WidgetsBinding.instance.addPostFrameCallback((_) { final listener = _listener; diff --git a/test/snapshot_partida_online_test.dart b/test/snapshot_partida_online_test.dart new file mode 100644 index 0000000..634ea74 --- /dev/null +++ b/test/snapshot_partida_online_test.dart @@ -0,0 +1,70 @@ +import 'package:farolero/modelos/jugador.dart'; +import 'package:farolero/modelos/partida.dart'; +import 'package:farolero/modelos/snapshot_partida_online.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('serializa resultado online preservando acentos y caracteres especiales', + () { + final partida = Partida( + config: const ConfigPartida(categoria: 'animales'), + jugadores: [ + Jugador(id: 'j1', nombre: 'León', esImpostor: true, eliminado: true), + Jugador(id: 'j2', nombre: 'María'), + Jugador(id: 'j3', nombre: 'Óscar'), + ], + palabraSecreta: 'Camión', + categoriaReal: 'Animales fantásticos', + fase: FaseJuego.resultado, + historialVotaciones: const [ + ResultadoVotacion( + eliminadoId: 'j1', + eliminadoNombre: 'León', + eraImpostor: true, + votos: {'j2': 'j1', 'j3': 'j1'}, + ), + ], + ); + + final snapshot = SnapshotPartidaOnline.desdePartida( + partida, + roomId: 'sala-áéíóú', + revelarPalabra: true, + revelarImpostores: true, + ); + + final reparsed = SnapshotPartidaOnline.fromJson(snapshot.toJson()); + + expect(reparsed.roomId, 'sala-áéíóú'); + expect(reparsed.categoria, 'Animales fantásticos'); + expect(reparsed.palabraSecreta, 'Camión'); + expect(reparsed.jugadores.map((jugador) => jugador.nombre), [ + 'León', + 'María', + 'Óscar', + ]); + expect(reparsed.resultadoActual?.eliminadoNombre, 'León'); + expect(reparsed.historialVotaciones.single.votos, {'j2': 'j1', 'j3': 'j1'}); + expect(reparsed.impostores, ['León']); + }); + + test('no revela palabra ni impostores salvo que el host lo indique', () { + final partida = Partida( + config: const ConfigPartida(categoria: 'lugares'), + jugadores: [ + Jugador(id: 'j1', nombre: 'Ana', esImpostor: true), + Jugador(id: 'j2', nombre: 'Beto'), + Jugador(id: 'j3', nombre: 'Carla'), + ], + palabraSecreta: 'Biblioteca', + categoriaReal: 'Lugares', + fase: FaseJuego.debate, + ); + + final datos = SnapshotPartidaOnline.desdePartida(partida).toJson(); + + expect(datos.containsKey('palabraSecreta'), isFalse); + expect(datos.containsKey('impostores'), isFalse); + expect(datos['jugadoresTodos'], hasLength(3)); + }); +}