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
+134 -2
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../modelos/partida.dart';
import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart';
import '../tema/tema_app.dart';
@@ -121,8 +122,8 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
return;
}
// 2. Pedir nombre del host
final nombre = await _pedirNombreHost();
// 2. Seleccionar o crear usuario del pool
final nombre = await _seleccionarUsuarioHost();
if (nombre == null || nombre.trim().isEmpty) return;
// 3. Iniciar host en Nearby
@@ -152,6 +153,10 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
onIniciar: () {
// Cuando el host toca "Iniciar" con suficientes jugadores
final estado = context.read<EstadoJuego>();
// Set host local player first (required for host-included game)
estado.setHostJugador(nombre.trim());
final jugadoresMulti = [
nombre.trim(),
...nearby.jugadores.map((j) => j.nombre),
@@ -207,6 +212,133 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
}
}
/// Muestra diálogo para seleccionar usuario del pool o crear nuevo
Future<String?> _seleccionarUsuarioHost() async {
final l10n = AppLocalizations.of(context)!;
final nearby = context.read<ServicioNearby>();
final usuarios = nearby.usuarios;
// Si hay usuarios en el pool, mostrar selección
if (usuarios.isNotEmpty) {
String? seleccionado;
return showDialog<String>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: Text(l10n.selectYourProfile),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
value: seleccionado,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.person),
hintText: l10n.selectProfile,
border: const OutlineInputBorder(),
),
items: [
// Opción para crear nuevo usuario
DropdownMenuItem<String>(
value: '_new_',
child: Row(
children: [
const Icon(Icons.add, size: 18),
const SizedBox(width: 8),
Text(l10n.createNewUser),
],
),
),
// Usuarios existentes
...usuarios.map((usuario) {
return DropdownMenuItem<String>(
value: usuario.nombre,
child: Row(
children: [
Text(usuario.avatar ?? '👤'),
const SizedBox(width: 8),
Text(usuario.nombre),
],
),
);
}),
],
onChanged: (valor) {
setDialogState(() => seleccionado = valor);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
if (seleccionado == '_new_') {
Navigator.pop(ctx);
_crearNuevoUsuarioHost();
} else if (seleccionado != null) {
Navigator.pop(ctx, seleccionado);
}
},
child: Text(l10n.accept),
),
],
),
),
);
}
// Pool vacío, pedir nombre nuevo
return _pedirNombreHost();
}
/// Crea un nuevo usuario y lo agrega al pool
Future<String?> _crearNuevoUsuarioHost() async {
final controller = TextEditingController();
final l10n = AppLocalizations.of(context)!;
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: const Text('OK'),
),
],
),
);
if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario(
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
);
nearby.agregarUsuario(nuevoUsuario);
return nombre.trim();
}
return null;
}
/// Método original para pedir nombre (usado cuando pool vacío)
Future<String?> _pedirNombreHost() async {
final controller = TextEditingController();
final l10n = AppLocalizations.of(context)!;
+123 -9
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.dart';
@@ -22,6 +23,7 @@ class PantallaLobbyHost extends StatefulWidget {
class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
bool _iniciando = false;
String? _perfilSeleccionado;
@override
Widget build(BuildContext context) {
@@ -66,6 +68,65 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
),
const SizedBox(height: 24),
// Selección de perfil
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.selectYourProfile,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _perfilSeleccionado,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.person),
hintText: l10n.selectProfile,
border: const OutlineInputBorder(),
),
items: [
// Opción para crear nuevo usuario
DropdownMenuItem<String>(
value: '_new_',
child: Row(
children: [
const Icon(Icons.add, size: 18),
const SizedBox(width: 8),
Text(l10n.createNewUser),
],
),
),
// Usuarios existentes
...nearby.usuarios.map((usuario) {
return DropdownMenuItem<String>(
value: usuario.nombre,
child: Row(
children: [
Text(usuario.avatar ?? '👤'),
const SizedBox(width: 8),
Text(usuario.nombre),
],
),
);
}),
],
onChanged: (valor) {
if (valor == '_new_') {
_crearNuevoUsuario(context);
} else {
setState(() => _perfilSeleccionado = valor);
}
},
),
],
),
),
),
const SizedBox(height: 16),
// Lista de jugadores
Expanded(
child: Column(
@@ -116,7 +177,10 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📱', style: TextStyle(fontSize: 48)),
const Text(
'📱',
style: TextStyle(fontSize: 48),
),
const SizedBox(height: 12),
Text(
l10n.waitingForPlayers,
@@ -141,15 +205,26 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
if (totalJugadores < 3)
Text(
l10n.needMorePlayers(3 - totalJugadores),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: TemaApp.colorNaranja,
),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja),
),
const SizedBox(height: 12),
if (_perfilSeleccionado == null)
Text(
l10n.selectProfile,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: totalJugadores >= 3 && !_iniciando
onPressed:
totalJugadores >= 3 &&
_perfilSeleccionado != null &&
!_iniciando
? () {
setState(() => _iniciando = true);
widget.onIniciar();
@@ -181,10 +256,7 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 20)),
const SizedBox(width: 12),
Expanded(
child: Text(
nombre,
style: Theme.of(context).textTheme.titleMedium,
),
child: Text(nombre, style: Theme.of(context).textTheme.titleMedium),
),
if (esHost)
Container(
@@ -206,4 +278,46 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
),
);
}
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(),
);
nearby.agregarUsuario(nuevoUsuario);
setState(() => _perfilSeleccionado = nombre.trim());
}
}
}
+183 -47
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) {