From 7dd6c7bd74f63b53b8f2c4603b02cc009555a7d4 Mon Sep 17 00:00:00 2001 From: freetlab Date: Mon, 4 May 2026 20:23:47 +0200 Subject: [PATCH] Mejora flujo de datos en partidas multidispositivos --- lib/pantallas/pantalla_debate_cliente.dart | 32 +++ lib/pantallas/pantalla_gestor_host.dart | 229 ++++++++++++++++++- lib/pantallas/pantalla_resultado.dart | 169 +++++++++++--- lib/pantallas/pantalla_unirse.dart | 7 +- lib/pantallas/pantalla_votacion_cliente.dart | 164 ++++++++++++- 5 files changed, 557 insertions(+), 44 deletions(-) diff --git a/lib/pantallas/pantalla_debate_cliente.dart b/lib/pantallas/pantalla_debate_cliente.dart index b9c25b6..dea4a18 100644 --- a/lib/pantallas/pantalla_debate_cliente.dart +++ b/lib/pantallas/pantalla_debate_cliente.dart @@ -7,11 +7,13 @@ import 'package:farolero/tema/tema_app.dart'; /// El cliente recibe el cambio de fase via Nearby y se navega aquí. class PantallaDebateCliente extends StatefulWidget { final int? tiempoDebateSegundos; + final String? primerTurnoNombre; final VoidCallback onSolicitarVotacion; const PantallaDebateCliente({ super.key, this.tiempoDebateSegundos, + this.primerTurnoNombre, required this.onSolicitarVotacion, }); @@ -111,6 +113,36 @@ class _PantallaDebateClienteState extends State { ], // Instrucciones + if (widget.primerTurnoNombre != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: TemaApp.colorNaranja.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: TemaApp.colorNaranja.withValues(alpha: 0.65), + ), + ), + child: Row( + children: [ + const Icon( + Icons.record_voice_over, + color: TemaApp.colorNaranja, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Empieza ${widget.primerTurnoNombre} diciendo su palabra.', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + Text( l10n.debateInstructions, textAlign: TextAlign.center, diff --git a/lib/pantallas/pantalla_gestor_host.dart b/lib/pantallas/pantalla_gestor_host.dart index 4b6cdc0..6f16042 100644 --- a/lib/pantallas/pantalla_gestor_host.dart +++ b/lib/pantallas/pantalla_gestor_host.dart @@ -1,9 +1,11 @@ import 'dart:async'; +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/inicio_partida_multijugador.dart'; +import '../modelos/jugador.dart'; import '../modelos/partida.dart'; import '../servicios/servicio_nearby.dart'; import '../tema/componentes_farolero.dart'; @@ -23,6 +25,9 @@ class PantallaGestorHost extends StatefulWidget { class _PantallaGestorHostState extends State { Timer? _timer; int _segundosRestantes = 0; + bool _hostListo = false; + String? _primerTurnoId; + String? _primerTurnoNombre; final Map _clientesListos = {}; final Map _votosRecibidos = {}; @@ -85,7 +90,15 @@ class _PantallaGestorHostState extends State { switch (fase) { case FaseJuego.debate: estado.iniciarDebate(); - nearby.enviarCambioFase('debate'); + final primero = _elegirPrimerTurno(); + nearby.enviarCambioFase('debate', { + 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: @@ -123,7 +136,8 @@ class _PantallaGestorHostState extends State { ); } - final todosListos = _clientesListos.length >= nearby.jugadores.length; + final todosListos = + _hostListo && _clientesListos.length >= nearby.jugadores.length; final todosVotaron = estado.todosHanVotado(); return Scaffold( @@ -224,6 +238,8 @@ class _PantallaGestorHostState extends State { return _buildFaseDebate(context, l10n, nearby); case FaseJuego.votacion: return _buildFaseVotacion(context, l10n, todosVotaron, nearby); + case FaseJuego.resultado: + return _buildFaseResultado(context, l10n); default: return const Center(child: Text('Fin de la partida')); } @@ -251,7 +267,7 @@ class _PantallaGestorHostState extends State { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - _buildJugadorTile(nearby.miNombre ?? 'Host', true, false), + _buildJugadorTile(nearby.miNombre ?? 'Host', true, _hostListo), ...nearby.jugadores.map( (j) => _buildJugadorTile( j.nombre, @@ -331,7 +347,10 @@ class _PantallaGestorHostState extends State { pistaCategoria: partida.config.pistaImpostor ? partida.categoriaReal : null, - onTodosVistos: () => Navigator.of(context).pop(), + onTodosVistos: () { + setState(() => _hostListo = true); + Navigator.of(context).pop(); + }, ), ), ); @@ -351,11 +370,28 @@ class _PantallaGestorHostState extends State { palabra: partida.palabraSecreta, pistaActiva: partida.config.pistaImpostor, categoria: partida.categoriaReal, + onVisto: () => setState(() => _hostListo = true), ), ), ); } + Jugador? _elegirPrimerTurno() { + final partida = context.read().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, @@ -399,6 +435,8 @@ class _PantallaGestorHostState extends State { ), const SizedBox(height: 16), ], + _buildPrimerTurno(context), + const SizedBox(height: 16), Text( l10n.activePlayers, style: Theme.of(context).textTheme.titleMedium, @@ -426,6 +464,31 @@ class _PantallaGestorHostState extends State { ); } + 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: [ + const Icon(Icons.record_voice_over, color: TemaApp.colorNaranja), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Empieza $nombre diciendo su palabra.', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + ); + } Widget _buildFaseVotacion( BuildContext context, AppLocalizations l10n, @@ -525,6 +588,145 @@ class _PantallaGestorHostState extends State { ); } + Widget _buildFaseResultado(BuildContext context, AppLocalizations l10n) { + final partida = context.watch().partida; + final resultado = partida?.historialVotaciones.isNotEmpty == true + ? partida!.historialVotaciones.last + : null; + if (partida == null || resultado == null) { + return const Center(child: Text('Sin 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)); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.result, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + 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, + ), + const SizedBox(height: 6), + Text( + resultado.eraImpostor + ? l10n.wasImpostor + : l10n.wasInnocent, + style: TextStyle( + color: resultado.eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text(l10n.votesThisRound, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Expanded( + child: ListView( + children: [ + ...ranking.map((entry) { + final jugador = partida.jugadores.firstWhere( + (j) => j.id == entry.key, + orElse: () => partida.jugadores.first, + ); + return _buildBarraResultado( + context, + nombre: jugador.nombre, + votos: entry.value, + maxVotos: maxVotos, + destacado: entry.key == resultado.eliminadoId, + ); + }), + const Divider(height: 24), + ...resultado.votos.entries.map((entry) { + final votante = partida.jugadores.firstWhere( + (j) => j.id == entry.key, + orElse: () => partida.jugadores.first, + ); + final votado = partida.jugadores.firstWhere( + (j) => j.id == entry.value, + orElse: () => partida.jugadores.first, + ); + return ListTile( + dense: true, + leading: const Icon(Icons.how_to_vote), + title: Text('${votante.nombre} → ${votado.nombre}'), + ); + }), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildBarraResultado( + BuildContext context, { + required String nombre, + required int votos, + required int maxVotos, + required bool destacado, + }) { + 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(nombre)), + Text('$votos', + style: TextStyle(color: color, fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: (votos / maxVotos).clamp(0.0, 1.0).toDouble(), + minHeight: 10, + backgroundColor: TemaApp.colorSuperficie, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + } + bool _hostYaVoto(BuildContext context) { final estado = context.read(); final sala = context.read().estadoSala; @@ -653,6 +855,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget { final String palabra; final bool pistaActiva; final String categoria; + final VoidCallback onVisto; const _PantallaRevelarPalabraHost({ required this.nombre, @@ -660,6 +863,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget { required this.palabra, required this.pistaActiva, required this.categoria, + required this.onVisto, }); @override @@ -736,7 +940,7 @@ class _PantallaRevelarPalabraHostState if (widget.esImpostor && widget.pistaActiva) ...[ const SizedBox(height: 12), Text( - 'Categoria: ${widget.categoria}', + 'Categoría: ${widget.categoria}', style: Theme.of(context).textTheme.bodyLarge ?.copyWith(color: TemaApp.colorNaranja), ), @@ -745,7 +949,7 @@ class _PantallaRevelarPalabraHostState ) : Column( children: [ - const Text('Candado', style: TextStyle(fontSize: 48)), + const Text('🔒', style: TextStyle(fontSize: 48)), const SizedBox(height: 16), Text( l10n.holdToSeeWord, @@ -787,6 +991,19 @@ class _PantallaRevelarPalabraHostState ), ), ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + widget.onVisto(); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.check), + label: Text(l10n.iveSeenIt), + ), + ), ], ), ), diff --git a/lib/pantallas/pantalla_resultado.dart b/lib/pantallas/pantalla_resultado.dart index 51c4b5a..60b8431 100644 --- a/lib/pantallas/pantalla_resultado.dart +++ b/lib/pantallas/pantalla_resultado.dart @@ -130,41 +130,7 @@ class _PantallaResultadoState extends State ), const SizedBox(height: 24), - // Detalle de votos - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.votesThisRound, - style: Theme.of(context) - .textTheme - .titleMedium), - const SizedBox(height: 8), - ...widget.resultado.votos.entries.map((e) { - final votante = partida?.jugadores - .firstWhere((j) => j.id == e.key); - final votado = partida?.jugadores - .firstWhere((j) => j.id == e.value); - return Padding( - padding: - const EdgeInsets.symmetric(vertical: 2), - child: Text( - '${votante?.nombre ?? '?'} → ${votado?.nombre ?? '?'}', - style: TextStyle( - color: e.value == - widget.resultado.eliminadoId - ? TemaApp.colorAcento - : TemaApp.colorTextoSecundario, - ), - ), - ); - }), - ], - ), - ), - ), + _buildDetalleVotos(context, partida, l10n), const SizedBox(height: 24), // Acciones @@ -181,6 +147,139 @@ class _PantallaResultadoState extends State ); } + Widget _buildDetalleVotos( + BuildContext context, + Partida? partida, + AppLocalizations l10n, + ) { + final jugadores = { + for (final jugador in partida?.jugadores ?? []) jugador.id: jugador, + }; + final conteo = {}; + for (final votadoId in widget.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)); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.bar_chart, color: TemaApp.colorNaranja), + const SizedBox(width: 8), + Text( + l10n.votesThisRound, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 12), + ...ranking.map((entry) { + final jugador = jugadores[entry.key]; + final eliminado = entry.key == widget.resultado.eliminadoId; + return _buildBarraVotos( + context, + nombre: jugador?.nombre ?? '?', + votos: entry.value, + total: maxVotos, + destacado: eliminado, + ); + }), + const Divider(height: 24), + ...widget.resultado.votos.entries.map((entry) { + final votante = jugadores[entry.key]?.nombre ?? '?'; + final votado = jugadores[entry.value]?.nombre ?? '?'; + final fueAlEliminado = + entry.value == widget.resultado.eliminadoId; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + fueAlEliminado + ? Icons.how_to_vote + : Icons.arrow_forward, + size: 18, + color: fueAlEliminado + ? TemaApp.colorAcento + : TemaApp.colorTextoSecundario, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$votante → $votado', + style: TextStyle( + color: fueAlEliminado + ? TemaApp.colorTexto + : TemaApp.colorTextoSecundario, + fontWeight: fueAlEliminado + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ); + } + + Widget _buildBarraVotos( + BuildContext context, { + required String nombre, + required int votos, + required int total, + required bool destacado, + }) { + final color = destacado ? TemaApp.colorAcento : TemaApp.colorNaranja; + final proporcion = total == 0 ? 0.0 : votos / total; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + nombre, + style: TextStyle( + fontWeight: destacado ? FontWeight.bold : FontWeight.w600, + ), + ), + ), + Text( + '$votos', + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: proporcion.clamp(0.0, 1.0).toDouble(), + minHeight: 10, + backgroundColor: TemaApp.colorSuperficie, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + } Widget _construirBotones(BuildContext context, EstadoJuego estado) { final l10n = AppLocalizations.of(context)!; final partida = estado.partida; diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index d5c4aed..99b5f9d 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -147,10 +147,14 @@ class _PantallaUnirseState extends State { void _navegarSegunFase(String fase) { switch (fase) { case 'debate': + final datosFase = context.read().datosPartida; Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => PantallaDebateCliente( - tiempoDebateSegundos: null, + tiempoDebateSegundos: + datosFase?['tiempoDebateSegundos'] as int?, + primerTurnoNombre: + datosFase?['primerTurnoNombre'] as String?, onSolicitarVotacion: () { final nearby = context.read(); if (nearby.hostEndpointId != null) { @@ -190,7 +194,6 @@ class _PantallaUnirseState extends State { ); } } - Navigator.of(context).pop(); }, ), ), diff --git a/lib/pantallas/pantalla_votacion_cliente.dart b/lib/pantallas/pantalla_votacion_cliente.dart index 1e938ef..baf088d 100644 --- a/lib/pantallas/pantalla_votacion_cliente.dart +++ b/lib/pantallas/pantalla_votacion_cliente.dart @@ -1,8 +1,10 @@ -import 'package:flutter/material.dart'; +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/servicios/servicio_nearby.dart'; import 'package:farolero/tema/tema_app.dart'; +import 'package:provider/provider.dart'; /// Pantalla de votación para cliente multidispositivo. /// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto @@ -25,6 +27,9 @@ class PantallaVotacionCliente extends StatefulWidget { class _PantallaVotacionClienteState extends State { final Map _votosPorVotante = {}; + Map? _resultado; + OnMensajeCallback? _listener; + ServicioNearby? _nearby; List get _votantes => widget.jugadoresControlados; bool get _modoMultiVotante => _votantes.length > 1; @@ -33,9 +38,35 @@ class _PantallaVotacionClienteState extends State { return _votantes.every((votante) => _votosPorVotante[votante.jugadorId] != null); } + @override + void initState() { + super.initState(); + _listener = (endpointId, mensaje) { + if (mensaje.tipo != TipoMensaje.votacionResultado || !mounted) return; + setState(() => _resultado = 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(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + if (_resultado != null) return _buildResultado(context, _resultado!); return Scaffold( backgroundColor: TemaApp.colorFondo, @@ -96,6 +127,137 @@ class _PantallaVotacionClienteState extends State { ); } + Widget _buildResultado(BuildContext context, Map resultado) { + final eliminadoId = resultado['eliminadoId'] as String?; + final eliminadoNombre = resultado['eliminadoNombre'] as String? ?? '?'; + final eraImpostor = resultado['eraImpostor'] as bool? ?? false; + final votosRaw = resultado['votos'] as Map? ?? {}; + final votos = votosRaw.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ); + final jugadores = {for (final jugador in widget.jugadores) jugador.id: jugador}; + final conteo = {}; + for (final votadoId in 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)); + + return Scaffold( + backgroundColor: TemaApp.colorFondo, + appBar: AppBar( + title: const Text('Resultado'), + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: eraImpostor + ? TemaApp.colorVerde.withValues(alpha: 0.18) + : TemaApp.colorAcento.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: eraImpostor ? TemaApp.colorVerde : TemaApp.colorAcento, + ), + ), + child: Column( + children: [ + Text( + eliminadoNombre, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + eraImpostor ? 'Era impostor' : 'Era inocente', + style: TextStyle( + color: eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + Text( + 'Detalle de votos', + 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 == 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), + ...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'), + ); + }), + ], + ), + ), + ], + ), + ), + ); + } + Widget _buildSelectorLegacy() { return ListView.builder( itemCount: widget.jugadores.length,