6e5e423ab4
No se puede marcar “vista” sin revelar la palabra antes. Se puede volver a ver la palabra durante debate/votación/resultado. Notas online privadas por partida y jugador. Tests añadidos para notas scoped. Ajusté roomId en el payload de inicio para que las notas no se mezclen entre partidas.
415 lines
14 KiB
Dart
415 lines
14 KiB
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/pantallas/pantalla_notas_online.dart';
|
|
import 'package:farolero/pantallas/pantalla_revision_palabra.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
|
|
/// por cada jugador controlado activo.
|
|
class PantallaVotacionCliente extends StatefulWidget {
|
|
final List<Jugador> jugadores;
|
|
final List<JugadorInicioPartida> jugadoresControlados;
|
|
final String? partidaId;
|
|
final String? pistaCategoria;
|
|
final Function(Map<String, String> votos) onVotos;
|
|
|
|
const PantallaVotacionCliente({
|
|
super.key,
|
|
required this.jugadores,
|
|
this.jugadoresControlados = const [],
|
|
this.partidaId,
|
|
this.pistaCategoria,
|
|
required this.onVotos,
|
|
});
|
|
|
|
@override
|
|
State<PantallaVotacionCliente> createState() => _PantallaVotacionClienteState();
|
|
}
|
|
|
|
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
|
|
final Map<String, String> _votosPorVotante = {};
|
|
Map<String, dynamic>? _resultado;
|
|
OnMensajeCallback? _listener;
|
|
ServicioNearby? _nearby;
|
|
|
|
List<JugadorInicioPartida> get _votantes => widget.jugadoresControlados;
|
|
bool get _modoMultiVotante => _votantes.length > 1;
|
|
bool get _votacionCompleta {
|
|
if (_votantes.isEmpty) return _votosPorVotante.containsKey('_legacy');
|
|
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<ServicioNearby>();
|
|
_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,
|
|
appBar: AppBar(
|
|
title: Text(l10n.voting),
|
|
automaticallyImplyLeading: false,
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
actions: [
|
|
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: _puedeAbrirNotas
|
|
? () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaNotasOnline(
|
|
partidaId: widget.partidaId!,
|
|
jugadores: widget.jugadores,
|
|
autoresControlados: widget.jugadoresControlados,
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.whoDoYouThinkIsTheImpostor,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_modoMultiVotante
|
|
? 'Emití un voto por cada jugador que manejás.'
|
|
: l10n.selectOnePlayer,
|
|
style: TextStyle(color: TemaApp.colorTextoSecundario),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: _votantes.isEmpty
|
|
? _buildSelectorLegacy()
|
|
: ListView.builder(
|
|
itemCount: _votantes.length,
|
|
itemBuilder: (context, index) {
|
|
final votante = _votantes[index];
|
|
return _buildSelectorParaVotante(context, votante);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 56,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _votacionCompleta
|
|
? () => widget.onVotos(Map.unmodifiable(_votosPorVotante))
|
|
: null,
|
|
icon: const Icon(Icons.how_to_vote),
|
|
label: Text(l10n.votar),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: TemaApp.colorAcento,
|
|
foregroundColor: Colors.white,
|
|
textStyle: const TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool get _puedeAbrirNotas {
|
|
return widget.partidaId != null &&
|
|
widget.jugadores.isNotEmpty &&
|
|
widget.jugadoresControlados.isNotEmpty;
|
|
}
|
|
|
|
Widget _buildResultado(BuildContext context, Map<String, dynamic> resultado) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
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<dynamic, dynamic>? ?? {};
|
|
final votos = votosRaw.map(
|
|
(key, value) => MapEntry(key.toString(), value.toString()),
|
|
);
|
|
final jugadores = {for (final jugador in widget.jugadores) jugador.id: jugador};
|
|
final conteo = <String, int>{};
|
|
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,
|
|
actions: [
|
|
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: _puedeAbrirNotas
|
|
? () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaNotasOnline(
|
|
partidaId: widget.partidaId!,
|
|
jugadores: widget.jugadores,
|
|
autoresControlados: widget.jugadoresControlados,
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
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,
|
|
itemBuilder: (context, index) {
|
|
final jugador = widget.jugadores[index];
|
|
final selected = _votosPorVotante['_legacy'] == jugador.id;
|
|
return _buildJugadorVotable(
|
|
jugador: jugador,
|
|
index: index,
|
|
selected: selected,
|
|
onTap: () => setState(() => _votosPorVotante['_legacy'] = jugador.id),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectorParaVotante(
|
|
BuildContext context,
|
|
JugadorInicioPartida votante,
|
|
) {
|
|
return Card(
|
|
color: TemaApp.colorSuperficie,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Voto de ${votante.nombre}',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
...widget.jugadores.asMap().entries.map((entry) {
|
|
final jugador = entry.value;
|
|
final selected = _votosPorVotante[votante.jugadorId] == jugador.id;
|
|
return _buildJugadorVotable(
|
|
jugador: jugador,
|
|
index: entry.key,
|
|
selected: selected,
|
|
onTap: () => setState(
|
|
() => _votosPorVotante[votante.jugadorId] = jugador.id,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildJugadorVotable({
|
|
required Jugador jugador,
|
|
required int index,
|
|
required bool selected,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return Card(
|
|
color: selected
|
|
? TemaApp.colorAcento.withValues(alpha: 0.3)
|
|
: TemaApp.colorTarjeta,
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: selected
|
|
? TemaApp.colorAcento
|
|
: TemaApp.colorAcento.withValues(alpha: 0.3),
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: TextStyle(
|
|
color: selected ? Colors.white : TemaApp.colorTexto,
|
|
),
|
|
),
|
|
),
|
|
title: Text(jugador.nombre),
|
|
trailing: selected
|
|
? const Icon(Icons.check_circle, color: TemaApp.colorAcento)
|
|
: null,
|
|
onTap: onTap,
|
|
),
|
|
);
|
|
}
|
|
}
|