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.
1065 lines
38 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|