fix: multidispositivo - Random seguro + gestor host + reacción clientes
- Random.secure() para selección de impostores (no predecible) - Random.secure() también en desempate de votación - Nueva PantallaGestorHost para coordinación multi-device - Navegación: host va a gestor tras iniciar, no a pantalla de palabra - PantallaPalabraCliente: cada jugador ve su palabra en su móvil - PantallaDebateCliente: debate con timer y botón solicitar votación - PantallaVotacionCliente: voto desde el móvil del cliente - PantallaUnirse: listener que reacciona a partidaInicio y cambia de fase - Protocolo: listo/voto/solicitoVotacion via Nearby hacia el host - Nuevas cadenas l10n ES
This commit is contained in:
@@ -7,7 +7,9 @@ import '../modelos/partida.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../servicios/servicio_permisos.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_gestor_host.dart';
|
||||
import 'pantalla_lobby_host.dart';
|
||||
import 'pantalla_principal.dart';
|
||||
import 'pantalla_ver_palabra.dart';
|
||||
|
||||
class PantallaCrearPartida extends StatefulWidget {
|
||||
@@ -30,23 +32,28 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
|
||||
int get _maxImpostores => (_jugadores.length / 3).floor().clamp(1, 4);
|
||||
|
||||
List<String> _etiquetasTiempo(AppLocalizations l10n) =>
|
||||
[l10n.noLimit, l10n.oneMin, l10n.twoMin, l10n.threeMin, l10n.fiveMin];
|
||||
List<String> _etiquetasTiempo(AppLocalizations l10n) => [
|
||||
l10n.noLimit,
|
||||
l10n.oneMin,
|
||||
l10n.twoMin,
|
||||
l10n.threeMin,
|
||||
l10n.fiveMin,
|
||||
];
|
||||
|
||||
void _agregarJugador() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final nombre = _controladorNombre.text.trim();
|
||||
if (nombre.isEmpty) return;
|
||||
if (_jugadores.contains(nombre)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.playerAlreadyExists)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.playerAlreadyExists)));
|
||||
return;
|
||||
}
|
||||
if (_jugadores.length >= 20) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.maxPlayersReached)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.maxPlayersReached)));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -76,9 +83,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
}
|
||||
|
||||
if (_jugadores.length < 3) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.minPlayersRequired)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.minPlayersRequired)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,7 +113,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
if (!permisosOk) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Se necesitan permisos de Bluetooth y ubicación')),
|
||||
const SnackBar(
|
||||
content: Text('Se necesitan permisos de Bluetooth y ubicación'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -125,7 +134,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
if (!ok) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No se pudo crear la sala. Verifica Bluetooth.')),
|
||||
const SnackBar(
|
||||
content: Text('No se pudo crear la sala. Verifica Bluetooth.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -163,7 +174,8 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
final jugadorNearby = nearby.jugadores[i];
|
||||
// El jugador [0] es el host, los de nearby son [1..n]
|
||||
final jugadorPartida = partida.jugadores[i + 1];
|
||||
impostores[jugadorNearby.endpointId] = jugadorPartida.esImpostor;
|
||||
impostores[jugadorNearby.endpointId] =
|
||||
jugadorPartida.esImpostor;
|
||||
}
|
||||
|
||||
nearby.enviarInicioPartida(
|
||||
@@ -174,7 +186,19 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const PantallaVerPalabra()),
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaGestorHost(
|
||||
onPartidaFin: () {
|
||||
estado.limpiar();
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const PantallaPrincipal(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -241,8 +265,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.gameMode,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
l10n.gameMode,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<bool>(
|
||||
segments: [
|
||||
@@ -275,8 +301,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.category,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
l10n.category,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -288,7 +316,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
items: categorias.map((c) {
|
||||
return DropdownMenuItem(
|
||||
value: c,
|
||||
child: Text(BancoPalabras.nombreBonitoCategoria(c, l10n)),
|
||||
child: Text(
|
||||
BancoPalabras.nombreBonitoCategoria(c, l10n),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (v) => setState(() => _categoria = v!),
|
||||
@@ -310,10 +340,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(l10n.playersCount(_jugadores.length),
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(l10n.playersRangeHint,
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
Text(
|
||||
l10n.playersCount(_jugadores.length),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text(
|
||||
l10n.playersRangeHint,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -342,13 +376,17 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: TemaApp.colorTarjeta,
|
||||
child: Text('${e.key + 1}',
|
||||
style:
|
||||
const TextStyle(color: TemaApp.colorTexto)),
|
||||
child: Text(
|
||||
'${e.key + 1}',
|
||||
style: const TextStyle(color: TemaApp.colorTexto),
|
||||
),
|
||||
),
|
||||
title: Text(e.value),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close, color: TemaApp.colorAcento),
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: TemaApp.colorAcento,
|
||||
),
|
||||
onPressed: () => _eliminarJugador(e.key),
|
||||
),
|
||||
dense: true,
|
||||
@@ -367,8 +405,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.configuration,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
l10n.configuration,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Número de impostores
|
||||
@@ -384,10 +424,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
: null,
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
),
|
||||
Text('$_numImpostores',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge),
|
||||
Text(
|
||||
'$_numImpostores',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _numImpostores < _maxImpostores
|
||||
? () => setState(() => _numImpostores++)
|
||||
@@ -404,8 +444,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
title: Text(l10n.impostorClue),
|
||||
subtitle: Text(l10n.impostorClueDescription),
|
||||
value: _pistaImpostor,
|
||||
onChanged: (v) =>
|
||||
setState(() => _pistaImpostor = v),
|
||||
onChanged: (v) => setState(() => _pistaImpostor = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
@@ -423,8 +462,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
child: Text(etiquetas[i]),
|
||||
),
|
||||
),
|
||||
onChanged: (v) =>
|
||||
setState(() => _tiempoDebate = v),
|
||||
onChanged: (v) => setState(() => _tiempoDebate = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -439,7 +477,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: (_modoMultimovil || _jugadores.length >= 3) ? _iniciarPartida : null,
|
||||
onPressed: (_modoMultimovil || _jugadores.length >= 3)
|
||||
? _iniciarPartida
|
||||
: null,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text(l10n.startGame),
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
||||
156
lib/pantallas/pantalla_debate_cliente.dart
Normal file
156
lib/pantallas/pantalla_debate_cliente.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/tema/tema_app.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í.
|
||||
class PantallaDebateCliente extends StatefulWidget {
|
||||
final int? tiempoDebateSegundos;
|
||||
final VoidCallback onSolicitarVotacion;
|
||||
|
||||
const PantallaDebateCliente({
|
||||
super.key,
|
||||
this.tiempoDebateSegundos,
|
||||
required this.onSolicitarVotacion,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PantallaDebateCliente> createState() => _PantallaDebateClienteState();
|
||||
}
|
||||
|
||||
class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
|
||||
Timer? _timer;
|
||||
int _segundosRestantes = 0;
|
||||
bool _votacionSolicitada = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.tiempoDebateSegundos != null) {
|
||||
_segundosRestantes = widget.tiempoDebateSegundos!;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_segundosRestantes > 0) {
|
||||
setState(() => _segundosRestantes--);
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatearTiempo(int segundos) {
|
||||
final min = segundos ~/ 60;
|
||||
final seg = segundos % 60;
|
||||
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: TemaApp.colorFondo,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.debate),
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
// Timer
|
||||
if (widget.tiempoDebateSegundos != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: _segundosRestantes == 0
|
||||
? TemaApp.colorAcento.withValues(alpha: 0.3)
|
||||
: TemaApp.colorTarjeta,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
_segundosRestantes == 0
|
||||
? l10n.timeUp
|
||||
: l10n.timeRemaining,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_formatearTiempo(_segundosRestantes),
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _segundosRestantes == 0
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorTexto,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
] else ...[
|
||||
Text(
|
||||
l10n.debatePhaseActive,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Instrucciones
|
||||
Text(
|
||||
l10n.debateInstructions,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: TemaApp.colorTextoSecundario,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Botón solicitar votación
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _votacionSolicitada
|
||||
? null
|
||||
: () {
|
||||
setState(() => _votacionSolicitada = true);
|
||||
widget.onSolicitarVotacion();
|
||||
},
|
||||
icon: Icon(_votacionSolicitada ? Icons.hourglass_empty : Icons.how_to_vote),
|
||||
label: Text(
|
||||
_votacionSolicitada
|
||||
? l10n.votacionSolicitada
|
||||
: l10n.solicitarVotacion,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _votacionSolicitada
|
||||
? TemaApp.colorTarjeta
|
||||
: TemaApp.colorAcento,
|
||||
foregroundColor: Colors.white,
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
514
lib/pantallas/pantalla_gestor_host.dart
Normal file
514
lib/pantallas/pantalla_gestor_host.dart
Normal file
@@ -0,0 +1,514 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../estado/estado_juego.dart';
|
||||
import '../modelos/partida.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
|
||||
|
||||
class PantallaGestorHost extends StatefulWidget {
|
||||
final VoidCallback onPartidaFin;
|
||||
|
||||
const PantallaGestorHost({super.key, required this.onPartidaFin});
|
||||
|
||||
@override
|
||||
State<PantallaGestorHost> createState() => _PantallaGestorHostState();
|
||||
}
|
||||
|
||||
class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
Timer? _timer;
|
||||
int _segundosRestantes = 0;
|
||||
final Map<String, bool> _clientesListos = {};
|
||||
final Map<String, String> _votosRecibidos = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_iniciarTemporizador();
|
||||
_registrarListeners();
|
||||
}
|
||||
|
||||
void _iniciarTemporizador() {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final tiempo = estado.partida?.config.tiempoDebateSegundos;
|
||||
if (tiempo != null) {
|
||||
_segundosRestantes = tiempo;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_segundosRestantes > 0) {
|
||||
setState(() => _segundosRestantes--);
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _registrarListeners() {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
nearby.onMensaje((endpointId, mensaje) {
|
||||
if (mensaje.tipo == TipoMensaje.listo) {
|
||||
setState(() => _clientesListos[endpointId] = true);
|
||||
} else if (mensaje.tipo == TipoMensaje.voto) {
|
||||
final votanteId = mensaje.datos['votanteId'] as String?;
|
||||
final votoId = mensaje.datos['votoporId'] as String?;
|
||||
if (votanteId != null && votoId != null) {
|
||||
setState(() => _votosRecibidos[votanteId] = votoId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatearTiempo(int segundos) {
|
||||
final min = segundos ~/ 60;
|
||||
final seg = segundos % 60;
|
||||
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _avanzarAFase(FaseJuego fase) {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
|
||||
switch (fase) {
|
||||
case FaseJuego.debate:
|
||||
estado.iniciarDebate();
|
||||
nearby.enviarCambioFase('debate');
|
||||
_iniciarTemporizador();
|
||||
break;
|
||||
case FaseJuego.votacion:
|
||||
estado.iniciarVotacion();
|
||||
nearby.enviarCambioFase('votacion');
|
||||
_votosRecibidos.clear();
|
||||
break;
|
||||
case FaseJuego.resultado:
|
||||
final resultado = estado.procesarVotacion();
|
||||
if (resultado != null) {
|
||||
nearby.enviarResultadoVotacion({
|
||||
'eliminadoId': resultado.eliminadoId,
|
||||
'eliminadoNombre': resultado.eliminadoNombre,
|
||||
'eraImpostor': resultado.eraImpostor,
|
||||
'votos': resultado.votos,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final estado = context.watch<EstadoJuego>();
|
||||
final nearby = context.watch<ServicioNearby>();
|
||||
final partida = estado.partida;
|
||||
|
||||
if (partida == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.hostGame)),
|
||||
body: const Center(child: Text('Error: Sin partida')),
|
||||
);
|
||||
}
|
||||
|
||||
final numJugadores = partida.jugadores.length + 1;
|
||||
final todosListos = _clientesListos.length >= numJugadores - 1;
|
||||
final todosVotaron = _votosRecibidos.length >= numJugadores - 1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.hostGame),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await nearby.desconectar();
|
||||
widget.onPartidaFin();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildFaseIndicator(context, partida.fase, l10n),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: _buildContenidoFase(
|
||||
context,
|
||||
partida.fase,
|
||||
l10n,
|
||||
todosListos,
|
||||
todosVotaron,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildBotonAccion(
|
||||
context,
|
||||
partida.fase,
|
||||
l10n,
|
||||
todosListos,
|
||||
todosVotaron,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFaseIndicator(
|
||||
BuildContext context,
|
||||
FaseJuego fase,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final fases = [
|
||||
(FaseJuego.verPalabra, l10n.seeYourWord),
|
||||
(FaseJuego.debate, l10n.debate),
|
||||
(FaseJuego.votacion, l10n.voting),
|
||||
(FaseJuego.resultado, l10n.result),
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: fases.map((e) {
|
||||
final esActiva = fase == e.$1 || fase.index > e.$1.index;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: esActiva ? TemaApp.colorAcento : TemaApp.colorSuperficie,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
e.$2,
|
||||
style: TextStyle(
|
||||
color: esActiva ? Colors.white : TemaApp.colorTextoSecundario,
|
||||
fontWeight: esActiva ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContenidoFase(
|
||||
BuildContext context,
|
||||
FaseJuego fase,
|
||||
AppLocalizations l10n,
|
||||
bool todosListos,
|
||||
bool todosVotaron,
|
||||
) {
|
||||
final nearby = context.watch<ServicioNearby>();
|
||||
|
||||
switch (fase) {
|
||||
case FaseJuego.verPalabra:
|
||||
return _buildFaseVerPalabra(context, l10n, todosListos, nearby);
|
||||
case FaseJuego.debate:
|
||||
return _buildFaseDebate(context, l10n, nearby);
|
||||
case FaseJuego.votacion:
|
||||
return _buildFaseVotacion(context, l10n, todosVotaron, nearby);
|
||||
default:
|
||||
return const Center(child: Text('Fin de la partida'));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFaseVerPalabra(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
bool todosListos,
|
||||
ServicioNearby nearby,
|
||||
) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.waitingPlayersSeeWord,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.connectedPlayers,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildJugadorTile(nearby.miNombre ?? 'Host', true, false),
|
||||
...nearby.jugadores.map(
|
||||
(j) => _buildJugadorTile(
|
||||
j.nombre,
|
||||
false,
|
||||
_clientesListos[j.endpointId] ?? false,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (todosListos)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: TemaApp.colorVerde.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: TemaApp.colorVerde),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.allSeenStartDebate,
|
||||
style: const TextStyle(color: TemaApp.colorVerde),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFaseDebate(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
ServicioNearby nearby,
|
||||
) {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final tiempo = estado.partida?.config.tiempoDebateSegundos;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (tiempo != null) ...[
|
||||
Text(l10n.debate, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _segundosRestantes == 0
|
||||
? TemaApp.colorAcento.withValues(alpha: 0.3)
|
||||
: TemaApp.colorTarjeta,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
_segundosRestantes == 0
|
||||
? l10n.timeUp
|
||||
: l10n.timeRemaining,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
_formatearTiempo(_segundosRestantes),
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
l10n.activePlayers,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: nearby.jugadores.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return _buildJugadorTile(
|
||||
nearby.miNombre ?? 'Host',
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}
|
||||
final j = nearby.jugadores[index - 1];
|
||||
return _buildJugadorTile(j.nombre, false, true);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFaseVotacion(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
bool todosVotaron,
|
||||
ServicioNearby nearby,
|
||||
) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.voting, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: TemaApp.colorTarjeta,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.votesProgress(
|
||||
_votosRecibidos.length,
|
||||
nearby.jugadores.length + 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value:
|
||||
_votosRecibidos.length /
|
||||
(nearby.jugadores.length + 1),
|
||||
backgroundColor: TemaApp.colorSuperficie,
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
TemaApp.colorAcento,
|
||||
),
|
||||
minHeight: 8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.playersVoted,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: nearby.jugadores.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
final esHost = index == 0;
|
||||
final nombre = esHost
|
||||
? (nearby.miNombre ?? 'Host')
|
||||
: nearby.jugadores[index - 1].nombre;
|
||||
final haVotado =
|
||||
esHost || _votosRecibidos.containsKey(nombre);
|
||||
return _buildJugadorTile(nombre, esHost, haVotado);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (todosVotaron)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: TemaApp.colorVerde.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: TemaApp.colorVerde),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.allVoted,
|
||||
style: const TextStyle(color: TemaApp.colorVerde),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJugadorTile(String nombre, bool esHost, bool listo) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: listo
|
||||
? TemaApp.colorVerde.withValues(alpha: 0.2)
|
||||
: TemaApp.colorTarjeta,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 18)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(nombre)),
|
||||
if (listo)
|
||||
const Icon(Icons.check_circle, color: TemaApp.colorVerde, size: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBotonAccion(
|
||||
BuildContext context,
|
||||
FaseJuego fase,
|
||||
AppLocalizations l10n,
|
||||
bool todosListos,
|
||||
bool todosVotaron,
|
||||
) {
|
||||
switch (fase) {
|
||||
case FaseJuego.verPalabra:
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: todosListos
|
||||
? () => _avanzarAFase(FaseJuego.debate)
|
||||
: null,
|
||||
icon: const Icon(Icons.forum),
|
||||
label: Text(
|
||||
todosListos
|
||||
? l10n.allSeenStartDebate
|
||||
: l10n.waitingPlayersSeeWord,
|
||||
),
|
||||
),
|
||||
);
|
||||
case FaseJuego.debate:
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _avanzarAFase(FaseJuego.votacion),
|
||||
icon: const Icon(Icons.how_to_vote),
|
||||
label: Text(l10n.goToVoting),
|
||||
),
|
||||
);
|
||||
case FaseJuego.votacion:
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: todosVotaron
|
||||
? () => _avanzarAFase(FaseJuego.resultado)
|
||||
: null,
|
||||
icon: const Icon(Icons.visibility),
|
||||
label: Text(todosVotaron ? l10n.revealResult : l10n.waitingVoting),
|
||||
),
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
169
lib/pantallas/pantalla_palabra_cliente.dart
Normal file
169
lib/pantallas/pantalla_palabra_cliente.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/tema/tema_app.dart';
|
||||
|
||||
/// Pantalla que ve cada jugador cuando recibe su palabra (modo multidispositivo).
|
||||
/// El cliente recibe la palabra via ServicioNearby y se navega aquí.
|
||||
/// NO es la pantalla del host.
|
||||
class PantallaPalabraCliente extends StatefulWidget {
|
||||
final String palabra;
|
||||
final bool esImpostor;
|
||||
final String? pistaCategoria;
|
||||
final VoidCallback onVisto;
|
||||
|
||||
const PantallaPalabraCliente({
|
||||
super.key,
|
||||
required this.palabra,
|
||||
required this.esImpostor,
|
||||
this.pistaCategoria,
|
||||
required this.onVisto,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PantallaPalabraCliente> createState() => _PantallaPalabraClienteState();
|
||||
}
|
||||
|
||||
class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
|
||||
bool _palabraVisible = false;
|
||||
Timer? _timer;
|
||||
|
||||
void _togglePalabra() {
|
||||
setState(() => _palabraVisible = !_palabraVisible);
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: TemaApp.colorFondo,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
// Tarjeta de palabra
|
||||
GestureDetector(
|
||||
onTap: _togglePalabra,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: _palabraVisible
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorTarjeta,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: _palabraVisible
|
||||
? [
|
||||
BoxShadow(
|
||||
color: TemaApp.colorAcento.withValues(alpha: 0.4),
|
||||
blurRadius: 24,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
_palabraVisible ? Icons.visibility : Icons.visibility_off,
|
||||
color: _palabraVisible
|
||||
? Colors.white
|
||||
: TemaApp.colorTextoSecundario,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_palabraVisible ? widget.palabra : '???',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _palabraVisible
|
||||
? Colors.white
|
||||
: TemaApp.colorTextoSecundario,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Pista para impostores
|
||||
if (widget.esImpostor && widget.pistaCategoria != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: TemaApp.colorAcento.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.lightbulb, color: TemaApp.colorAcento),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'🎭 ${l10n.clueIs(widget.pistaCategoria!)}',
|
||||
style: const TextStyle(color: TemaApp.colorAcento),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Instrucciones
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_palabraVisible
|
||||
? 'Mantén la pantalla oculta. No la enseñes a nadie.'
|
||||
: 'Toca para ver tu palabra',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: TemaApp.colorTextoSecundario,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Botón confirmar
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
widget.onVisto();
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(l10n.iveSeenIt),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TemaApp.colorAcento,
|
||||
foregroundColor: Colors.white,
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import '../modelos/jugador.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../servicios/servicio_permisos.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_palabra_cliente.dart';
|
||||
import 'pantalla_debate_cliente.dart';
|
||||
import 'pantalla_votacion_cliente.dart';
|
||||
|
||||
/// Pantalla para unirse a una partida multidispositivo.
|
||||
/// Flujo: nombre → discovery automático (lista de salas) → fallback QR
|
||||
@@ -26,6 +30,110 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
String? _error;
|
||||
String? _salaSeleccionada;
|
||||
|
||||
// Estado del juego recibido del host
|
||||
String? _palabraRecibida;
|
||||
bool _esImpostor = false;
|
||||
String? _pistaCategoria;
|
||||
final List<Jugador> _jugadores = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Registrar listener ANTES del primer build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_registrarListenerPartida();
|
||||
});
|
||||
}
|
||||
|
||||
void _registrarListenerPartida() {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
nearby.onMensaje((endpointId, mensaje) {
|
||||
if (mensaje.tipo == TipoMensaje.partidaInicio) {
|
||||
// El host ha iniciado la partida — nos ha enviado nuestra palabra
|
||||
setState(() {
|
||||
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
||||
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
||||
_pistaCategoria = mensaje.datos['categoria'] as String?;
|
||||
});
|
||||
// Navegar a pantalla de palabra del cliente
|
||||
if (mounted && _palabraRecibida != null) {
|
||||
_navegarAPalabra();
|
||||
}
|
||||
} else if (mensaje.tipo == TipoMensaje.fase) {
|
||||
// El host cambia de fase — navegar a la pantalla correspondiente
|
||||
final fase = mensaje.datos['fase'] as String?;
|
||||
if (mounted && fase != null) {
|
||||
_navegarSegunFase(fase);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _navegarAPalabra() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaPalabraCliente(
|
||||
palabra: _palabraRecibida ?? '',
|
||||
esImpostor: _esImpostor,
|
||||
pistaCategoria: _pistaCategoria,
|
||||
onVisto: () {
|
||||
// Enviar "listo" al host y volver a la espera
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
if (nearby.hostEndpointId != null) {
|
||||
nearby.enviarMensaje(
|
||||
nearby.hostEndpointId!,
|
||||
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navegarSegunFase(String fase) {
|
||||
switch (fase) {
|
||||
case 'debate':
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaDebateCliente(
|
||||
tiempoDebateSegundos: null,
|
||||
onSolicitarVotacion: () {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
if (nearby.hostEndpointId != null) {
|
||||
nearby.enviarMensaje(
|
||||
nearby.hostEndpointId!,
|
||||
MensajeP2P(tipo: TipoMensaje.ping, datos: {'solicitoVotacion': true}),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'votacion':
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaVotacionCliente(
|
||||
jugadores: _jugadores,
|
||||
onVoto: (votoporId) {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
if (nearby.hostEndpointId != null) {
|
||||
nearby.enviarMensaje(
|
||||
nearby.hostEndpointId!,
|
||||
MensajeP2P(tipo: TipoMensaje.voto, datos: {'votoporId': votoporId}),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nombreController.dispose();
|
||||
|
||||
112
lib/pantallas/pantalla_votacion_cliente.dart
Normal file
112
lib/pantallas/pantalla_votacion_cliente.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/modelos/jugador.dart';
|
||||
import 'package:farolero/tema/tema_app.dart';
|
||||
|
||||
/// Pantalla de votación para el cliente (multidispositivo).
|
||||
/// El cliente recibe fase=votacion y ve esta pantalla para elegir a quién votar.
|
||||
class PantallaVotacionCliente extends StatefulWidget {
|
||||
final List<Jugador> jugadores;
|
||||
final Function(String votoporId) onVoto;
|
||||
|
||||
const PantallaVotacionCliente({
|
||||
super.key,
|
||||
required this.jugadores,
|
||||
required this.onVoto,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PantallaVotacionCliente> createState() => _PantallaVotacionClienteState();
|
||||
}
|
||||
|
||||
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
|
||||
String? _votoSeleccionado;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: TemaApp.colorFondo,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.voting),
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
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(
|
||||
l10n.selectOnePlayer,
|
||||
style: TextStyle(color: TemaApp.colorTextoSecundario),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: widget.jugadores.length,
|
||||
itemBuilder: (context, index) {
|
||||
final jugador = widget.jugadores[index];
|
||||
final selected = _votoSeleccionado == jugador.id;
|
||||
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: () {
|
||||
setState(() => _votoSeleccionado = jugador.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _votoSeleccionado == null
|
||||
? null
|
||||
: () => widget.onVoto(_votoSeleccionado!),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user