feat(multi-device): host puede participar como jugador

- Añadido modelo Usuario con pool de usuarios sincronizado
- El host ahora recibe palabra y rol como cualquier jugador
- UI de selección de perfil en pantallas de lobby
- Los clientes pueden ver usuarios del servidor o crear nuevos
- El juego no inicia hasta que el host selecciona perfil
This commit is contained in:
ShanaiaBot
2026-04-24 18:47:56 +02:00
parent 3df3ae1e95
commit d3fc3386f9
31 changed files with 1266 additions and 106 deletions

View File

@@ -3,6 +3,7 @@ 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/usuario.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart';
import '../tema/tema_app.dart';
@@ -104,7 +105,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.ping, datos: {'solicitoVotacion': true}),
MensajeP2P(
tipo: TipoMensaje.ping,
datos: {'solicitoVotacion': true},
),
);
}
},
@@ -122,7 +126,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.voto, datos: {'votoporId': votoporId}),
MensajeP2P(
tipo: TipoMensaje.voto,
datos: {'votoporId': votoporId},
),
);
}
Navigator.of(context).pop();
@@ -148,7 +155,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
final permisosOk = await ServicioPermisos.solicitarPermisosNearby(context);
if (!permisosOk) {
setState(() {
_error = 'Se necesitan permisos de Bluetooth y ubicación para buscar partidas.';
_error =
'Se necesitan permisos de Bluetooth y ubicación para buscar partidas.';
});
return;
}
@@ -164,7 +172,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
});
} else {
setState(() {
_error = 'No se pudo iniciar la búsqueda. Verifica Bluetooth y ubicación.';
_error =
'No se pudo iniciar la búsqueda. Verifica Bluetooth y ubicación.';
});
}
}
@@ -179,7 +188,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
final nearby = context.read<ServicioNearby>();
// Parar discovery antes de conectar
await nearby.pararBusqueda();
final ok = await nearby.conectarAHost(endpointId, _nombreController.text.trim());
final ok = await nearby.conectarAHost(
endpointId,
_nombreController.text.trim(),
);
if (!ok && mounted) {
setState(() {
@@ -211,7 +223,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
setState(() {
_escaneandoQR = false;
_conectando = true;
_salaSeleccionada = datos['host'] as String? ?? datos['sala'] as String? ?? 'Sala';
_salaSeleccionada =
datos['host'] as String? ?? datos['sala'] as String? ?? 'Sala';
});
// Iniciar búsqueda para que Nearby encuentre al host
@@ -252,9 +265,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
Widget _buildFormularioNombre(BuildContext context, AppLocalizations l10n) {
return Scaffold(
appBar: AppBar(
title: Text(l10n.joinGameTitle),
),
appBar: AppBar(title: Text(l10n.joinGameTitle)),
body: Padding(
padding: const EdgeInsets.all(32),
child: Form(
@@ -310,7 +321,11 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
// ==================== PASO 2: DISCOVERY ====================
Widget _buildDiscovery(BuildContext context, AppLocalizations l10n, ServicioNearby nearby) {
Widget _buildDiscovery(
BuildContext context,
AppLocalizations l10n,
ServicioNearby nearby,
) {
final hosts = nearby.hostsEncontrados;
return Scaffold(
@@ -345,7 +360,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
Row(
children: [
const SizedBox(
width: 20, height: 20,
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: TemaApp.colorNaranja,
@@ -378,9 +394,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
const SizedBox(height: 8),
Text(
l10n.noGamesFoundHint,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: Colors.grey),
textAlign: TextAlign.center,
),
],
@@ -406,9 +421,9 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
const SizedBox(height: 8),
Text(
l10n.orScanQR,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 8),
SizedBox(
@@ -451,14 +466,18 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
),
Text(
'Toca para unirte',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
],
),
),
const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey),
const Icon(
Icons.arrow_forward_ios,
size: 16,
color: Colors.grey,
),
],
),
),
@@ -499,9 +518,9 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
),
child: Text(
l10n.scanHostQR,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: Colors.white),
textAlign: TextAlign.center,
),
),
@@ -514,6 +533,9 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
// ==================== 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),
@@ -531,38 +553,152 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
},
),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('', style: TextStyle(fontSize: 64)),
const SizedBox(height: 24),
Text(
l10n.connectedWaiting,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'${l10n.yourName}: ${_nombreController.text}',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 32),
const CircularProgressIndicator(color: TemaApp.colorNaranja),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Estado de conexión
const Text('', style: TextStyle(fontSize: 64)),
const SizedBox(height: 24),
Text(
l10n.connectedWaiting,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'${l10n.yourName}: ${_nombreController.text}',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 32),
const CircularProgressIndicator(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.waitingForHost,
style: Theme.of(context).textTheme.bodyMedium,
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: const Icon(
Icons.add,
color: TemaApp.colorAcento,
),
title: Text(l10n.createNewUser),
onTap: () => _crearNuevoUsuario(context),
),
const Divider(),
// Usuarios existentes
...usuarios.map(
(usuario) => ListTile(
leading: Text(
usuario.avatar ?? '👤',
style: const TextStyle(fontSize: 24),
),
title: Text(usuario.nombre),
onTap: () {
// Seleccionar usuario - enviar al host
_enviarUsuarioAlHost(usuario);
},
),
),
],
),
),
),
] else ...[
const SizedBox(height: 16),
// Si no hay usuarios, permitir crear uno
OutlinedButton.icon(
onPressed: () => _crearNuevoUsuario(context),
icon: const Icon(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 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: const Icon(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) {
final nuevoUsuario = Usuario(
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
);
// Agregar localmente
nearby.agregarUsuario(nuevoUsuario);
// Enviar al host
_enviarUsuarioAlHost(nuevoUsuario);
}
}
/// Envía el usuario seleccionado/creado al host
void _enviarUsuarioAlHost(Usuario usuario) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuario.toJson()},
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado')));
}
}
// ==================== HELPERS ====================
Widget _buildError(String msg) {