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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user