Files
farolero/lib/pantallas/pantalla_unirse.dart
T
FreeTLab 863690168c feat(multidispositivo): reconnection, impostor awareness and protocol fixes
Fixes
- SnapshotPartidaOnline no longer serializes esImpostor unless the game is
  over. Every phase change was broadcasting the impostor roles in clear to
  all clients.
- The impostor clue travels in its own field and only when the game enables
  it. It used to be sent as the category key ("todas" when no category was
  picked) and was overwritten by each phase snapshot.
- Eliminated players can no longer vote nor be voted for, on both the client
  screen and the host listener.
- Nearby listeners are removed on dispose; votes are no longer dispatched
  twice; votacionResultado no longer triggers two navigations.
- _jugadoresHostControlados handed the secret word to the host's own
  impostors in the payload.
- Impostor cap unified as maxImpostoresPara(n) for both modes, and the
  silent clamp in multi-device now reports the effective number.
- palabraAleatoria uses Random.secure.

Impostors know each other
- ConfigPartida.impostoresSeConocen, on by default. companerosImpostores is
  nullable: null means nothing to show, an empty list means "you are the
  only impostor".

Reconnection
- IdentidadDispositivo persists a stable device id; the host uses it as the
  clientId so a phone that drops and returns is the same client. Falls back
  to the endpointId for clients that do not send one.
- Usuario.absorbidoDe records the original owner when the host takes over a
  disconnected player, and they are handed back automatically on return.
- New solicitarResync/resync messages: the host replies with the current
  phase plus that client's own players, and the client jumps straight to the
  running phase.
- Readiness is keyed by clientId instead of endpointId, and a disconnected
  client no longer blocks the round.
- The retry gives up after three minutes, only reconnects to the host it
  belonged to, and is cancelled correctly while shutting down.

Per-word clue support
- The word bank loader accepts both the old list-of-strings format and the
  new one carrying a clue per word, falling back to the category clue.

Not verified on real devices: Nearby reconnection timing still needs two
Android phones.
2026-07-25 20:36:32 +02:00

1065 lines
38 KiB
Dart

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 '../modelos/partida.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';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
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
class PantallaUnirse extends StatefulWidget {
const PantallaUnirse({super.key});
@override
State<PantallaUnirse> createState() => _PantallaUnirseState();
}
class _PantallaUnirseState extends State<PantallaUnirse> {
final _nombreController = TextEditingController();
final _formKey = GlobalKey<FormState>();
// Estados de la pantalla
bool _buscando = false;
bool _escaneandoQR = false;
bool _conectando = false;
String? _error;
String? _salaSeleccionada;
// Estado del juego recibido del host
String? _palabraRecibida;
bool _esImpostor = false;
String? _pistaImpostor;
String? _partidaId;
final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = [];
OnMensajeCallback? _listenerMensajes;
ServicioNearby? _nearby;
@override
void initState() {
super.initState();
// Registrar listener ANTES del primer build
WidgetsBinding.instance.addPostFrameCallback((_) {
final perfil = context.read<ServicioPerfilUsuario>().perfil;
if (_nombreController.text.isEmpty) {
_nombreController.text = perfil.nombre;
}
_registrarListenerPartida();
});
}
void _registrarListenerPartida() {
final nearby = context.read<ServicioNearby>();
_nearby = nearby;
_listenerMensajes = (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<dynamic>?;
final jugadoresTodosData =
mensaje.datos['jugadoresTodos'] as List<dynamic>?;
setState(() {
_jugadoresControlados
..clear()
..addAll(
(jugadoresData ?? []).map(
(json) => JugadorInicioPartida.fromJson(
json as Map<String, dynamic>,
),
),
);
_jugadores
..clear()
..addAll(
(jugadoresTodosData ?? []).map(
(json) => Jugador.fromJson(json as Map<String, dynamic>),
),
);
if (_jugadoresControlados.isNotEmpty) {
final primero = _jugadoresControlados.first;
_palabraRecibida = primero.palabra;
_esImpostor = primero.esImpostor;
} else {
_palabraRecibida = mensaje.datos['palabra'] as String?;
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
if (_palabraRecibida != null) {
_jugadoresControlados.add(
JugadorInicioPartida(
jugadorId: nearby.miClientId ?? '_legacy',
nombre: _nombreController.text.trim().isEmpty
? 'Jugador'
: _nombreController.text.trim(),
esImpostor: _esImpostor,
palabra: _palabraRecibida,
),
);
}
}
// La pista solo llega si la partida la tiene activada; la categoría
// no vale como pista porque puede ser 'todas'.
_pistaImpostor = mensaje.datos['pistaImpostor'] as String?;
_partidaId = (mensaje.datos['roomId'] as String?) ??
nearby.roomId ??
(mensaje.datos['clientId'] as String?) ??
DateTime.now().microsecondsSinceEpoch.toString();
});
// Navegar a pantalla de palabra del cliente
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
_navegarAPalabra();
}
} else if (mensaje.tipo == TipoMensaje.resync) {
_aplicarResync(mensaje.datos);
} else if (mensaje.tipo == TipoMensaje.fase) {
final fase = mensaje.datos['fase'] as String?;
_actualizarSnapshotSiExiste(mensaje.datos);
if (mounted && fase != null) {
_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);
}
};
nearby.onMensaje(_listenerMensajes!);
}
/// Reincorpora este móvil a una partida ya empezada tras una caída.
///
/// El host manda el estado completo, así que se reconstruye todo —jugadores
/// controlados, pista, censo— antes de saltar a la pantalla de la fase en
/// curso. Se descartan las rutas viejas: la pila de antes ya no vale.
void _aplicarResync(Map<String, dynamic> datos) {
final controlados = (datos['jugadores'] as List<dynamic>? ?? const [])
.map((json) => JugadorInicioPartida.fromJson(
json as Map<String, dynamic>,
))
.toList();
setState(() {
_jugadoresControlados
..clear()
..addAll(controlados);
_pistaImpostor = datos['pistaImpostor'] as String? ?? _pistaImpostor;
_partidaId = (datos['roomId'] as String?) ??
_partidaId ??
context.read<ServicioNearby>().roomId;
});
_actualizarSnapshotSiExiste(datos);
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
final fase = datos['fase'] as String?;
if (fase == null || fase == FaseJuego.verPalabra.name) {
// Aún no ha empezado el debate: que vuelva a ver su palabra.
if (_jugadoresControlados.isNotEmpty) _navegarAPalabra();
return;
}
_navegarSegunFase(fase, datos);
}
void _navegarAPalabra() {
if (_jugadoresControlados.isNotEmpty) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente(
jugadores: List.unmodifiable(_jugadoresControlados),
pistaImpostor: _pistaImpostor,
onTodosVistos: () {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
);
}
Navigator.of(context).pop();
},
),
),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PantallaPalabraCliente(
palabra: _palabraRecibida ?? '',
esImpostor: _esImpostor,
pistaImpostor: _pistaImpostor,
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 _actualizarSnapshotSiExiste(Map<String, dynamic> datos) {
final jugadoresTodosData = datos['jugadoresTodos'] as List<dynamic>?;
if (jugadoresTodosData == null) return;
setState(() {
_jugadores
..clear()
..addAll(
jugadoresTodosData.map(
(json) => Jugador.fromJson(json as Map<String, dynamic>),
),
);
_partidaId = (datos['roomId'] as String?) ??
_partidaId ??
context.read<ServicioNearby>().roomId;
});
}
void _navegarSegunFase(String fase, [Map<String, dynamic>? datos]) {
switch (fase) {
case 'debate':
final datosFase = datos ?? context.read<ServicioNearby>().datosPartida;
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaDebateCliente(
tiempoDebateSegundos:
datosFase?['tiempoDebateSegundos'] as int?,
primerTurnoNombre:
datosFase?['primerTurnoNombre'] as String?,
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaImpostor: _pistaImpostor,
jugadores: List.unmodifiable(_jugadores),
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
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,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaImpostor: _pistaImpostor,
onVotos: (votos) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
for (final entry in votos.entries) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.voto,
datos: {
'votanteId': entry.key,
'votadoId': entry.value,
'votoporId': entry.value,
},
),
);
}
}
},
),
),
);
break;
case 'resultado':
case 'adivinanza':
_navegarResultado(datos ?? context.read<ServicioNearby>().datosPartida);
break;
case 'finPartida':
_navegarFinPartida(datos ?? context.read<ServicioNearby>().datosPartida);
break;
}
}
void _navegarResultado(Map<String, dynamic>? datos) {
if (datos == null) return;
final snapshot = SnapshotPartidaOnline.fromJson(datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaResultadoOnline(
snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaImpostor: _pistaImpostor,
),
),
);
}
void _navegarFinPartida(Map<String, dynamic>? datos) {
if (datos == null) return;
final snapshot = SnapshotPartidaOnline.fromJson(datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaFinPartidaOnline(
snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaImpostor: _pistaImpostor,
),
),
);
}
@override
void dispose() {
final listener = _listenerMensajes;
if (listener != null) _nearby?.removeMensajeListener(listener);
_nombreController.dispose();
super.dispose();
}
/// Paso 1: validar nombre, pedir permisos e iniciar discovery
Future<void> _iniciarBusqueda() async {
if (!_formKey.currentState!.validate()) return;
final l10n = AppLocalizations.of(context)!;
// Solicitar permisos automáticamente
final permisosOk = await ServicioPermisos.solicitarPermisosNearby(context);
if (!permisosOk) {
setState(() {
_error = l10n.bluetoothLocationPermissionsRequired;
});
return;
}
if (!mounted) return;
final nearby = context.read<ServicioNearby>();
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
final ok = await nearby.buscarHosts(
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
if (ok) {
setState(() {
_buscando = true;
_error = null;
});
} else {
setState(() {
_error = l10n.couldNotStartSearch;
});
}
}
/// Conectar a un host de la lista
Future<void> _conectarAHost(String endpointId, String nombreHost) async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_conectando = true;
_salaSeleccionada = nombreHost;
});
final nearby = context.read<ServicioNearby>();
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
// Parar discovery antes de conectar
await nearby.pararBusqueda();
final ok = await nearby.conectarAHost(
endpointId,
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
if (!ok && mounted) {
setState(() {
_conectando = false;
_error = l10n.couldNotConnectToHost(nombreHost);
});
// Reiniciar búsqueda
_iniciarBusqueda();
}
}
/// Fallback: escanear QR
void _abrirEscaner() {
setState(() {
_escaneandoQR = true;
_error = null;
});
}
Future<void> _onQRDetectado(BarcodeCapture capture) async {
if (_conectando) return;
for (final barcode in capture.barcodes) {
final valor = barcode.rawValue;
if (valor == null) continue;
final datos = ServicioNearby.parsearQR(valor);
if (datos != null) {
final l10n = AppLocalizations.of(context)!;
setState(() {
_escaneandoQR = false;
_conectando = true;
_salaSeleccionada =
datos['host'] as String? ?? datos['sala'] as String? ?? l10n.room;
});
// Iniciar búsqueda para que Nearby encuentre al host
final nearby = context.read<ServicioNearby>();
if (!nearby.buscando) {
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
await nearby.buscarHosts(
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
}
return;
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>();
if (nearby.reconectando && !nearby.conectado) {
return _buildReconectando(context, l10n, nearby);
}
// Si estamos conectados → pantalla de espera
if (nearby.conectado && !nearby.esHost) {
return _buildPantallaEspera(context, l10n);
}
// Si escaneando QR
if (_escaneandoQR) {
return _buildEscaner(context, l10n);
}
// Si buscando hosts o conectando
if (_buscando || _conectando) {
return _buildDiscovery(context, l10n, nearby);
}
// Formulario nombre
return _buildFormularioNombre(context, l10n);
}
// ==================== PASO 1: NOMBRE ====================
Widget _buildFormularioNombre(BuildContext context, AppLocalizations l10n) {
return Scaffold(
appBar: AppBar(title: Text(l10n.joinGameTitle)),
body: FondoFarolero(
intenso: true,
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _JoinLobbySignalArt(height: 190),
const SizedBox(height: 14),
EncabezadoFarolero(
icono: Icons.bluetooth_searching,
assetIconPath: 'assets/ui/generated/actions/action_multidevice_signal.webp',
titulo: l10n.joinGameTitle,
subtitulo: l10n.enterNameToSearch,
color: TemaApp.colorAzul,
),
const SizedBox(height: 32),
TextFormField(
controller: _nombreController,
decoration: InputDecoration(
labelText: l10n.yourName,
prefixIcon: IconoFarolero(Icons.person),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return l10n.nameRequired;
return null;
},
textCapitalization: TextCapitalization.words,
onFieldSubmitted: (_) => _iniciarBusqueda(),
),
const SizedBox(height: 24),
BotonFarolero(
texto: l10n.searchGames,
icono: Icons.search,
assetIconPath: 'assets/ui/generated/actions/action_join_search.webp',
onPressed: _iniciarBusqueda,
),
if (_error != null) ...[
const SizedBox(height: 16),
_buildError(_error!),
],
],
),
),
),
),
),
);
}
// ==================== PASO 2: DISCOVERY ====================
Widget _buildDiscovery(
BuildContext context,
AppLocalizations l10n,
ServicioNearby nearby,
) {
final hosts = nearby.hostsEncontrados;
return Scaffold(
appBar: AppBar(
title: Text(l10n.joinGameTitle),
leading: IconButton(
icon: IconoFarolero(Icons.arrow_back),
onPressed: () async {
await nearby.pararBusqueda();
setState(() {
_buscando = false;
_conectando = false;
});
},
),
),
body: FondoFarolero(
intenso: true,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const _JoinLobbySignalArt(height: 150),
const SizedBox(height: 12),
EncabezadoFarolero(
icono: _conectando ? Icons.sync : Icons.radar,
assetIconPath: _conectando ? null : 'assets/ui/generated/actions/action_join_search.webp',
titulo: _conectando
? '${l10n.connectingTo} ${_salaSeleccionada ?? ""}...'
: l10n.searchingGames,
subtitulo: _conectando
? l10n.preparingSecureRoom
: l10n.searchingNearbyBluetoothGames,
color: _conectando ? TemaApp.colorAcento : TemaApp.colorNaranja,
trailing: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: _conectando
? TemaApp.colorAcento
: TemaApp.colorNaranja,
),
),
),
const SizedBox(height: 18),
// Lista de hosts encontrados
Expanded(
child: hosts.isEmpty && !_conectando
? Center(
child: EstadoVacioFarolero(
icono: Icons.radar,
assetIconPath: 'assets/ui/generated/actions/action_join_search.webp',
titulo: l10n.noGamesFound,
subtitulo: l10n.noGamesFoundHint,
),
)
: ListView.builder(
itemCount: hosts.length,
itemBuilder: (context, index) {
final entry = hosts.entries.elementAt(index);
return _buildHostTile(l10n, entry.key, entry.value);
},
),
),
if (_error != null) ...[
_buildError(_error!),
const SizedBox(height: 12),
],
// Fallback: escanear QR
if (!_conectando) ...[
const Divider(),
const SizedBox(height: 8),
Text(
l10n.orScanQR,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: BotonFarolero.oscuro(
texto: l10n.scanQR,
icono: Icons.qr_code_scanner,
assetIconPath: 'assets/ui/generated/actions/action_qr_scan.webp',
onPressed: _abrirEscaner,
),
),
],
],
),
),
),
);
}
Widget _buildHostTile(
AppLocalizations l10n,
String endpointId,
String nombre,
) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: _conectando ? null : () => _conectarAHost(endpointId, nombre),
child: Ink(
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorTarjeta.withValues(alpha: 0.90),
borderColor: TemaApp.colorNaranja.withValues(alpha: 0.42),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
children: [
IconoFarolero(
Icons.theater_comedy,
color: TemaApp.colorNaranja,
size: 30,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombre,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
l10n.tapToJoin,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconoFarolero(
Icons.arrow_forward_ios,
size: 16,
color: TemaApp.colorDorado,
),
],
),
),
),
),
),
);
}
// ==================== ESCÁNER QR ====================
Widget _buildEscaner(BuildContext context, AppLocalizations l10n) {
return Scaffold(
appBar: AppBar(
title: Text(l10n.scanQR),
leading: IconButton(
icon: IconoFarolero(Icons.arrow_back),
onPressed: () => setState(() => _escaneandoQR = false),
),
),
body: Stack(
children: [
MobileScanner(onDetect: _onQRDetectado),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.8),
],
),
),
child: Text(
l10n.scanHostQR,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
// ==================== RECONEXIÓN ====================
Widget _buildReconectando(
BuildContext context,
AppLocalizations l10n,
ServicioNearby nearby,
) {
return Scaffold(
appBar: AppBar(
title: Text(_salaSeleccionada ?? l10n.joinGameTitle),
automaticallyImplyLeading: false,
actions: [
IconButton(
tooltip: l10n.leaveGame,
icon: IconoFarolero(Icons.close),
onPressed: () async {
await nearby.desconectar();
if (!context.mounted) return;
setState(() {
_buscando = false;
_conectando = false;
});
},
),
],
),
body: FondoFarolero(
intenso: true,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _JoinLobbySignalArt(height: 180),
const SizedBox(height: 16),
EncabezadoFarolero(
icono: Icons.wifi_tethering_off,
titulo: l10n.reconnecting,
subtitulo: l10n.reconnectingHint,
color: TemaApp.colorNaranja,
trailing: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: TemaApp.colorNaranja,
),
),
),
],
),
),
),
),
);
}
// ==================== ESPERA ====================
Widget _buildPantallaEspera(BuildContext context, AppLocalizations l10n) {
final nearby = context.watch<ServicioNearby>();
final usuarios = nearby.usuarios;
return Scaffold(
appBar: AppBar(
title: Text(_salaSeleccionada ?? l10n.joinGameTitle),
leading: IconButton(
icon: IconoFarolero(Icons.close),
onPressed: () async {
final nearby = context.read<ServicioNearby>();
await nearby.desconectar();
if (context.mounted) {
setState(() {
_buscando = false;
_conectando = false;
});
}
},
),
),
body: FondoFarolero(
intenso: true,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const _JoinLobbySignalArt(height: 160),
const SizedBox(height: 12),
EncabezadoFarolero(
icono: Icons.check_circle,
titulo: l10n.connectedWaiting,
subtitulo: '${l10n.yourName}: ${_nombreController.text}',
color: TemaApp.colorVerde,
trailing: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: TemaApp.colorNaranja,
),
),
),
const SizedBox(height: 16),
Text(
l10n.waitingForHost,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
// Pool de usuarios disponibles (tarea 3.4)
if (usuarios.isNotEmpty) ...[
const Divider(),
const SizedBox(height: 16),
Text(
l10n.availableProfiles,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
// Opción crear nuevo usuario (tarea 3.5)
ListTile(
leading: IconoFarolero(
Icons.add,
color: TemaApp.colorAcento,
),
title: Text(l10n.createNewUser),
onTap: () => _crearNuevoUsuario(context),
),
const Divider(),
// Usuarios existentes
...usuarios.map(_buildUsuarioSalaTile),
],
),
),
),
] else ...[
const SizedBox(height: 16),
// Si no hay usuarios, permitir crear uno
OutlinedButton.icon(
onPressed: () => _crearNuevoUsuario(context),
icon: IconoFarolero(Icons.person_add),
label: Text(l10n.createNewUser),
),
],
],
),
),
),
);
}
/// Crea un nuevo usuario y lo envía al host
Future<void> _crearNuevoUsuario(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final gamificacion =
context.read<ServicioPerfilUsuario>().resumenGamificacion;
controller.text = perfil.nombre;
final nombre = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.createNewUser),
content: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
hintText: l10n.yourName,
prefixIcon: IconoFarolero(Icons.person),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: Text(l10n.accept),
),
],
),
);
if (nombre != null && nombre.trim().isNotEmpty) {
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
);
}
}
/// Envía el usuario seleccionado/creado al host
void _enviarUsuarioAlHost(Usuario usuario) {
final l10n = AppLocalizations.of(context)!;
final nearby = context.read<ServicioNearby>();
nearby.seleccionarUsuarioSala(usuario.id);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.profileSelected)));
}
Widget _buildUsuarioSalaTile(Usuario usuario) {
final l10n = AppLocalizations.of(context)!;
final nearby = context.read<ServicioNearby>();
final miClientId = nearby.miClientId;
final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId;
final seleccionadoPorOtro =
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
return ListTile(
minLeadingWidth: 58,
leading: SizedBox(
width: 58,
height: 58,
child: AvatarFarolero(
texto: usuario.nombre.isEmpty ? '?' : usuario.nombre[0],
assetPath: usuario.avatar,
size: 48,
fuego: usuario.fuego,
medallas: usuario.medallas,
),
),
title: Text(usuario.nombre),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
seleccionadoPorMi
? l10n.selectedOnThisPhone
: seleccionadoPorOtro
? l10n.notAvailable
: l10n.available,
),
if (usuario.medallas.isNotEmpty) ...[
const SizedBox(height: 4),
MedallasCompactasFarolero(ids: usuario.medallas),
],
],
),
trailing: seleccionadoPorMi
? IconButton(
icon: IconoFarolero(Icons.close),
onPressed: () => nearby.liberarUsuarioSala(usuario.id),
)
: null,
enabled: !seleccionadoPorOtro,
onTap: seleccionadoPorOtro
? null
: () {
if (seleccionadoPorMi) {
nearby.liberarUsuarioSala(usuario.id);
} else {
_enviarUsuarioAlHost(usuario);
}
},
);
}
// ==================== HELPERS ====================
Widget _buildError(String msg) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: TemaApp.colorAcento.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
msg,
style: const TextStyle(color: TemaApp.colorAcento),
textAlign: TextAlign.center,
),
);
}
}
class _JoinLobbySignalArt extends StatelessWidget {
final double height;
const _JoinLobbySignalArt({required this.height});
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
width: double.infinity,
child: Image.asset(
'assets/ui/generated/join_lobby/signal_art.webp',
fit: BoxFit.contain,
opacity: const AlwaysStoppedAnimation(0.94),
),
);
}
}