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

@@ -12,11 +12,17 @@ class EstadoJuego extends ChangeNotifier {
final Map<String, String> _votos = {}; // votanteId -> votadoId
bool _cargando = false;
/// Jugador local del host en modo multi-dispositivo
Jugador? _hostLocal;
BancoPalabras? get banco => _banco;
Partida? get partida => _partida;
Map<String, String> get votos => Map.unmodifiable(_votos);
bool get cargando => _cargando;
/// Jugador local del host (para modo multi-dispositivo)
Jugador? get hostLocal => _hostLocal;
Future<void> cargarBanco() async {
_cargando = true;
notifyListeners();
@@ -25,6 +31,16 @@ class EstadoJuego extends ChangeNotifier {
notifyListeners();
}
/// Establece el jugador local del host para modo multi-dispositivo
void setHostJugador(String nombre) {
_hostLocal = Jugador(
id: 'host-local',
nombre: nombre,
endpointId: null, // El host local no tiene endpointId
);
notifyListeners();
}
/// Crea una nueva partida con la configuración dada y lista de jugadores
void crearPartida({
required ConfigPartida config,

View File

@@ -255,5 +255,11 @@
"searchingGames": "Searching for nearby games...",
"noGamesFound": "No games found",
"noGamesFoundHint": "Make sure the host has the room open and you are nearby",
"orScanQR": "Not showing up? Scan the host's QR code"
}
"orScanQR": "Not showing up? Scan the host's QR code",
"selectYourProfile": "Your profile",
"selectProfile": "Select a profile",
"createNewUser": "Create new user",
"userNameRequired": "Name cannot be empty",
"profileSelected": "Profile selected",
"availableProfiles": "Available profiles"
}

View File

@@ -277,5 +277,11 @@
"votacionSolicitada": "Votación solicitada",
"whoDoYouThinkIsTheImpostor": "¿Quién es el impostor?",
"selectOnePlayer": "Selecciona a un jugador para votar",
"votar": "Votar"
}
"votar": "Votar",
"selectYourProfile": "Tu perfil",
"selectProfile": "Selecciona un perfil",
"createNewUser": "Crear nuevo usuario",
"userNameRequired": "El nombre no puede estar vacio",
"profileSelected": "Perfil seleccionado",
"availableProfiles": "Perfiles disponibles"
}

View File

@@ -1196,6 +1196,42 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Votar'**
String get votar;
/// No description provided for @selectYourProfile.
///
/// In es, this message translates to:
/// **'Tu perfil'**
String get selectYourProfile;
/// No description provided for @selectProfile.
///
/// In es, this message translates to:
/// **'Selecciona un perfil'**
String get selectProfile;
/// No description provided for @createNewUser.
///
/// In es, this message translates to:
/// **'Crear nuevo usuario'**
String get createNewUser;
/// No description provided for @userNameRequired.
///
/// In es, this message translates to:
/// **'El nombre no puede estar vacio'**
String get userNameRequired;
/// No description provided for @profileSelected.
///
/// In es, this message translates to:
/// **'Perfil seleccionado'**
String get profileSelected;
/// No description provided for @availableProfiles.
///
/// In es, this message translates to:
/// **'Perfiles disponibles'**
String get availableProfiles;
}
class _AppLocalizationsDelegate

View File

@@ -579,4 +579,22 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -582,4 +582,22 @@ class AppLocalizationsCa extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -585,4 +585,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -579,4 +579,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Your profile';
@override
String get selectProfile => 'Select a profile';
@override
String get createNewUser => 'Create new user';
@override
String get userNameRequired => 'Name cannot be empty';
@override
String get profileSelected => 'Profile selected';
@override
String get availableProfiles => 'Available profiles';
}

View File

@@ -581,4 +581,22 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -584,4 +584,22 @@ class AppLocalizationsEu extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -582,4 +582,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -581,4 +581,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -582,4 +582,22 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -579,4 +579,22 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -579,4 +579,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -582,4 +582,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -582,4 +582,22 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -583,4 +583,22 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -582,4 +582,22 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -581,4 +581,22 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}

View File

@@ -578,6 +578,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get votar => 'Votar';
@override
String get selectYourProfile => 'Tu perfil';
@override
String get selectProfile => 'Selecciona un perfil';
@override
String get createNewUser => 'Crear nuevo usuario';
@override
String get userNameRequired => 'El nombre no puede estar vacio';
@override
String get profileSelected => 'Perfil seleccionado';
@override
String get availableProfiles => 'Perfiles disponibles';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).

20
lib/modelos/usuario.dart Normal file
View File

@@ -0,0 +1,20 @@
/// Modelo de usuario para el pool de usuarios en modo multi-dispositivo
class Usuario {
final String id;
final String nombre;
final String? avatar;
Usuario({required this.id, required this.nombre, this.avatar});
Map<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
if (avatar != null) 'avatar': avatar,
};
factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
id: json['id'] as String,
nombre: json['nombre'] as String,
avatar: json['avatar'] as String?,
);
}

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)!;

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());
}
}
}

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) {

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:nearby_connections/nearby_connections.dart';
import '../modelos/usuario.dart';
/// Tipos de mensajes en el protocolo P2P
enum TipoMensaje {
@@ -14,6 +15,9 @@ enum TipoMensaje {
listo,
ping,
jugadorDesconectado,
usuarioNuevo,
usuarioEliminado,
usuariosActualizados,
}
/// Mensaje del protocolo P2P entre dispositivos
@@ -23,10 +27,7 @@ class MensajeP2P {
MensajeP2P({required this.tipo, required this.datos});
String toJson() => json.encode({
'tipo': tipo.name,
'datos': datos,
});
String toJson() => json.encode({'tipo': tipo.name, 'datos': datos});
factory MensajeP2P.fromJson(String jsonStr) {
final mapa = json.decode(jsonStr) as Map<String, dynamic>;
@@ -53,7 +54,8 @@ class JugadorConectado {
}
/// Callback para mensajes recibidos
typedef OnMensajeCallback = void Function(String endpointId, MensajeP2P mensaje);
typedef OnMensajeCallback =
void Function(String endpointId, MensajeP2P mensaje);
/// Servicio para conexiones P2P usando Google Nearby Connections API.
class ServicioNearby extends ChangeNotifier {
@@ -80,6 +82,9 @@ class ServicioNearby extends ChangeNotifier {
String? _faseActual;
Map<String, dynamic>? _datosPartida;
// Pool de usuarios para modo multi-dispositivo
final Map<String, Usuario> _usuariosPool = {};
bool get esHost => _esHost;
bool get conectado => _conectado;
bool get buscando => _buscando;
@@ -95,7 +100,11 @@ class ServicioNearby extends ChangeNotifier {
List<JugadorConectado> get jugadores => _jugadores.values.toList();
int get numJugadoresConectados => _jugadores.length;
Map<String, String> get hostsEncontrados => Map.unmodifiable(_hostsEncontrados);
Map<String, String> get hostsEncontrados =>
Map.unmodifiable(_hostsEncontrados);
/// Pool de usuarios disponibles para seleccionar en modo multi-dispositivo
List<Usuario> get usuarios => _usuariosPool.values.toList();
/// Registra un listener de mensajes
void onMensaje(OnMensajeCallback callback) {
@@ -113,6 +122,45 @@ class ServicioNearby extends ChangeNotifier {
}
}
// ==================== USER POOL ====================
/// Agrega un usuario al pool de usuarios
void agregarUsuario(Usuario usuario) {
_usuariosPool[usuario.id] = usuario;
notifyListeners();
}
/// Elimina un usuario del pool
void eliminarUsuario(String usuarioId) {
_usuariosPool.remove(usuarioId);
notifyListeners();
}
/// Obtiene un usuario por su ID
Usuario? getUsuario(String usuarioId) {
return _usuariosPool[usuarioId];
}
/// Sincroniza el pool de usuarios con una lista
void sincronizarUsuarios(List<Usuario> usuarios) {
_usuariosPool.clear();
for (final usuario in usuarios) {
_usuariosPool[usuario.id] = usuario;
}
notifyListeners();
}
/// Obtiene el jugador local del host (él mismo como participante)
/// Retorna un JugadorConectado con endpointId null porque es local
JugadorConectado? getJugadorLocal() {
if (_miNombre == null) return null;
return JugadorConectado(
endpointId: _miEndpointId ?? '', // vacío indica que es el host local
nombre: _miNombre!,
listo: true,
);
}
// ==================== HOST ====================
/// Inicia como host (anunciando el endpoint)
@@ -211,10 +259,13 @@ class ServicioNearby extends ChangeNotifier {
_hostEndpointId = endpointId;
_conectado = true;
// Enviar mensaje de unirse
enviarMensaje(endpointId, MensajeP2P(
tipo: TipoMensaje.unirse,
datos: {'nombre': _miNombre ?? 'Jugador'},
));
enviarMensaje(
endpointId,
MensajeP2P(
tipo: TipoMensaje.unirse,
datos: {'nombre': _miNombre ?? 'Jugador'},
),
);
}
notifyListeners();
} else {
@@ -228,10 +279,12 @@ class ServicioNearby extends ChangeNotifier {
final jugador = _jugadores.remove(endpointId);
if (jugador != null) {
// Notificar a todos que se desconectó
enviarATodos(MensajeP2P(
tipo: TipoMensaje.jugadorDesconectado,
datos: {'nombre': jugador.nombre, 'endpointId': endpointId},
));
enviarATodos(
MensajeP2P(
tipo: TipoMensaje.jugadorDesconectado,
datos: {'nombre': jugador.nombre, 'endpointId': endpointId},
),
);
}
} else {
// Cliente perdió conexión con host
@@ -241,7 +294,11 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners();
}
void _onEndpointEncontrado(String endpointId, String endpointName, String serviceId) {
void _onEndpointEncontrado(
String endpointId,
String endpointName,
String serviceId,
) {
debugPrint('Host encontrado: $endpointName ($endpointId)');
_hostsEncontrados[endpointId] = endpointName;
notifyListeners();
@@ -303,17 +360,20 @@ class ServicioNearby extends ChangeNotifier {
endpointId: endpointId,
nombre: nombre,
);
// Enviar info de sala al nuevo jugador
enviarMensaje(endpointId, MensajeP2P(
tipo: TipoMensaje.salaInfo,
datos: {
'sala': _nombreSala,
'jugadores': _jugadores.values.map((j) => {
'nombre': j.nombre,
'endpointId': j.endpointId,
}).toList(),
},
));
// Enviar info de sala al nuevo jugador (incluye pool de usuarios)
enviarMensaje(
endpointId,
MensajeP2P(
tipo: TipoMensaje.salaInfo,
datos: {
'sala': _nombreSala,
'jugadores': _jugadores.values
.map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId})
.toList(),
'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(),
},
),
);
notifyListeners();
break;
@@ -330,15 +390,62 @@ class ServicioNearby extends ChangeNotifier {
}
break;
case TipoMensaje.usuarioNuevo:
_handleUsuarioNuevo(mensaje);
break;
case TipoMensaje.usuariosActualizados:
_handleUsuariosActualizados(mensaje);
break;
default:
break;
}
}
void _handleUsuarioNuevo(MensajeP2P mensaje) {
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
if (usuarioJson != null) {
final nuevoUsuario = Usuario.fromJson(usuarioJson);
_usuariosPool[nuevoUsuario.id] = nuevoUsuario;
// Propagar a todos los clientes
if (_esHost) {
enviarATodos(
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuarioJson},
),
);
}
notifyListeners();
}
}
void _handleUsuariosActualizados(MensajeP2P mensaje) {
final usuariosJson = mensaje.datos['usuarios'] as List<dynamic>?;
if (usuariosJson != null) {
_usuariosPool.clear();
for (final u in usuariosJson) {
final usuario = Usuario.fromJson(u as Map<String, dynamic>);
_usuariosPool[usuario.id] = usuario;
}
notifyListeners();
}
}
void _procesarMensajeCliente(String endpointId, MensajeP2P mensaje) {
switch (mensaje.tipo) {
case TipoMensaje.salaInfo:
_datosPartida = mensaje.datos;
// Sincronizar pool de usuarios si viene en el mensaje
final usuariosData = mensaje.datos['usuarios'] as List<dynamic>?;
if (usuariosData != null) {
_usuariosPool.clear();
for (final u in usuariosData) {
final usuario = Usuario.fromJson(u as Map<String, dynamic>);
_usuariosPool[usuario.id] = usuario;
}
}
notifyListeners();
break;
@@ -402,38 +509,42 @@ class ServicioNearby extends ChangeNotifier {
}) async {
for (final entry in _jugadores.entries) {
final esImpostor = impostores[entry.key] ?? false;
await enviarMensaje(entry.key, MensajeP2P(
tipo: TipoMensaje.partidaInicio,
datos: {
'palabra': esImpostor ? null : palabraSecreta,
'esImpostor': esImpostor,
'categoria': categoria,
'numJugadores': _jugadores.length + 1, // +1 por el host
},
));
await enviarMensaje(
entry.key,
MensajeP2P(
tipo: TipoMensaje.partidaInicio,
datos: {
'palabra': esImpostor ? null : palabraSecreta,
'esImpostor': esImpostor,
'categoria': categoria,
'numJugadores': _jugadores.length + 1, // +1 por el host
},
),
);
}
}
/// Host envía cambio de fase
Future<void> enviarCambioFase(String fase, [Map<String, dynamic>? extra]) async {
Future<void> enviarCambioFase(
String fase, [
Map<String, dynamic>? extra,
]) async {
final datos = {'fase': fase, ...?extra};
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
}
/// Host envía resultado de votación
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
await enviarATodos(MensajeP2P(
tipo: TipoMensaje.votacionResultado,
datos: resultado,
));
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado),
);
}
/// Host envía fin de partida
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
await enviarATodos(MensajeP2P(
tipo: TipoMensaje.partidaFin,
datos: resultado,
));
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado),
);
}
// ==================== LIMPIEZA ====================