diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0eb9ffe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Code Review Rules + +## Flutter / Dart + +- Use functional components with Flutter hooks when possible +- Follow Clean Architecture: pantallas, modelos, servicios, estado +- Use Provider for state management +- Use flutter_test for unit testing +- Run flutter analyze before committing +- Use conventional commits: feat, fix, chore, docs, etc. + +## General + +- No Co-Authored-By in commits +- Conventional commit format +- Test before push diff --git a/lib/estado/estado_juego.dart b/lib/estado/estado_juego.dart index 179b3aa..f766c8e 100644 --- a/lib/estado/estado_juego.dart +++ b/lib/estado/estado_juego.dart @@ -12,11 +12,17 @@ class EstadoJuego extends ChangeNotifier { final Map _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 get votos => Map.unmodifiable(_votos); bool get cargando => _cargando; + /// Jugador local del host (para modo multi-dispositivo) + Jugador? get hostLocal => _hostLocal; + Future 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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2c2fc56..50fa224 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" -} \ No newline at end of file + "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" +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b023446..2924cf1 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -277,5 +277,11 @@ "votacionSolicitada": "Votación solicitada", "whoDoYouThinkIsTheImpostor": "¿Quién es el impostor?", "selectOnePlayer": "Selecciona a un jugador para votar", - "votar": "Votar" -} \ No newline at end of file + "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" +} diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 7d5b63e..8146f45 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -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 diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index e3b41c6..4010b52 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_ca.dart b/lib/l10n/generated/app_localizations_ca.dart index af2f3cf..9b556ee 100644 --- a/lib/l10n/generated/app_localizations_ca.dart +++ b/lib/l10n/generated/app_localizations_ca.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a573e60..dc1a443 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 1df577b..071e74d 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 27bb952..b930033 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_eu.dart b/lib/l10n/generated/app_localizations_eu.dart index 30ac7fe..dae67c0 100644 --- a/lib/l10n/generated/app_localizations_eu.dart +++ b/lib/l10n/generated/app_localizations_eu.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 950fbbf..dc1b82d 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 7ad471c..59727a0 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 4665660..a9e92b1 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 9f678a0..bb7900b 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 5264563..b686d0e 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index aca3d93..dc84c4a 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 01d2084..97e9578 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 23476ce..a9d94e6 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 8043790..ee68f53 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 544ba41..45a0e87 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index a2bf61c..9eb33f3 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -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`). diff --git a/lib/modelos/usuario.dart b/lib/modelos/usuario.dart new file mode 100644 index 0000000..d0a6768 --- /dev/null +++ b/lib/modelos/usuario.dart @@ -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 toJson() => { + 'id': id, + 'nombre': nombre, + if (avatar != null) 'avatar': avatar, + }; + + factory Usuario.fromJson(Map json) => Usuario( + id: json['id'] as String, + nombre: json['nombre'] as String, + avatar: json['avatar'] as String?, + ); +} diff --git a/lib/pantallas/pantalla_crear_partida.dart b/lib/pantallas/pantalla_crear_partida.dart index fcf7c57..93c9a48 100644 --- a/lib/pantallas/pantalla_crear_partida.dart +++ b/lib/pantallas/pantalla_crear_partida.dart @@ -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 { 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 { onIniciar: () { // Cuando el host toca "Iniciar" con suficientes jugadores final estado = context.read(); + + // 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 { } } + /// Muestra diálogo para seleccionar usuario del pool o crear nuevo + Future _seleccionarUsuarioHost() async { + final l10n = AppLocalizations.of(context)!; + final nearby = context.read(); + final usuarios = nearby.usuarios; + + // Si hay usuarios en el pool, mostrar selección + if (usuarios.isNotEmpty) { + String? seleccionado; + return showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: Text(l10n.selectYourProfile), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + value: seleccionado, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.person), + hintText: l10n.selectProfile, + border: const OutlineInputBorder(), + ), + items: [ + // Opción para crear nuevo usuario + DropdownMenuItem( + 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( + 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 _crearNuevoUsuarioHost() async { + final controller = TextEditingController(); + final l10n = AppLocalizations.of(context)!; + final nearby = context.read(); + + final nombre = await showDialog( + 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 _pedirNombreHost() async { final controller = TextEditingController(); final l10n = AppLocalizations.of(context)!; diff --git a/lib/pantallas/pantalla_lobby_host.dart b/lib/pantallas/pantalla_lobby_host.dart index 9a1542f..2c2930a 100644 --- a/lib/pantallas/pantalla_lobby_host.dart +++ b/lib/pantallas/pantalla_lobby_host.dart @@ -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 { bool _iniciando = false; + String? _perfilSeleccionado; @override Widget build(BuildContext context) { @@ -66,6 +68,65 @@ class _PantallaLobbyHostState extends State { ), 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( + value: _perfilSeleccionado, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.person), + hintText: l10n.selectProfile, + border: const OutlineInputBorder(), + ), + items: [ + // Opción para crear nuevo usuario + DropdownMenuItem( + 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( + 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 { 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 { 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 { 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 { ), ); } + + Future _crearNuevoUsuario(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + final nearby = context.read(); + + final nombre = await showDialog( + 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()); + } + } } diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index a41605d..7a575a2 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -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 { 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 { 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 { 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 { }); } 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 { final nearby = context.read(); // 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 { 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 { 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 { // ==================== 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 { 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 { 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 { 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 { ), 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 { ), 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 { // ==================== ESPERA ==================== Widget _buildPantallaEspera(BuildContext context, AppLocalizations l10n) { + final nearby = context.watch(); + final usuarios = nearby.usuarios; + return Scaffold( appBar: AppBar( title: Text(_salaSeleccionada ?? l10n.joinGameTitle), @@ -531,38 +553,152 @@ class _PantallaUnirseState extends State { }, ), ), - 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 _crearNuevoUsuario(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + final nearby = context.read(); + + final nombre = await showDialog( + 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(); + 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) { diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart index 96a19e6..a07e578 100644 --- a/lib/servicios/servicio_nearby.dart +++ b/lib/servicios/servicio_nearby.dart @@ -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; @@ -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? _datosPartida; + // Pool de usuarios para modo multi-dispositivo + final Map _usuariosPool = {}; + bool get esHost => _esHost; bool get conectado => _conectado; bool get buscando => _buscando; @@ -95,7 +100,11 @@ class ServicioNearby extends ChangeNotifier { List get jugadores => _jugadores.values.toList(); int get numJugadoresConectados => _jugadores.length; - Map get hostsEncontrados => Map.unmodifiable(_hostsEncontrados); + Map get hostsEncontrados => + Map.unmodifiable(_hostsEncontrados); + + /// Pool de usuarios disponibles para seleccionar en modo multi-dispositivo + List 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 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?; + 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?; + if (usuariosJson != null) { + _usuariosPool.clear(); + for (final u in usuariosJson) { + final usuario = Usuario.fromJson(u as Map); + _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?; + if (usuariosData != null) { + _usuariosPool.clear(); + for (final u in usuariosData) { + final usuario = Usuario.fromJson(u as Map); + _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 enviarCambioFase(String fase, [Map? extra]) async { + Future enviarCambioFase( + String fase, [ + Map? extra, + ]) async { final datos = {'fase': fase, ...?extra}; await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos)); } /// Host envía resultado de votación Future enviarResultadoVotacion(Map 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 enviarFinPartida(Map resultado) async { - await enviarATodos(MensajeP2P( - tipo: TipoMensaje.partidaFin, - datos: resultado, - )); + await enviarATodos( + MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado), + ); } // ==================== LIMPIEZA ==================== diff --git a/test/estado_juego_crear_partida_host_test.dart b/test/estado_juego_crear_partida_host_test.dart new file mode 100644 index 0000000..828e33d --- /dev/null +++ b/test/estado_juego_crear_partida_host_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/estado/estado_juego.dart'; +import 'package:farolero/modelos/palabra.dart'; +import 'package:farolero/modelos/partida.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('EstadoJuego crearPartida with host local', () { + late EstadoJuego estado; + + setUp(() async { + estado = EstadoJuego(); + await estado.cargarBanco(); + }); + + tearDown(() { + estado.dispose(); + }); + + test( + 'should include host in jugadores list when setHostJugador called', + () { + // Set host local player first + estado.setHostJugador('HostJuan'); + + // Create game with 3 client players + host (host is in the list) + estado.crearPartida( + config: ConfigPartida( + modoMultimovil: true, + categoria: 'objetos', + numImpostores: 1, + pistaImpostor: false, + ), + nombresJugadores: ['HostJuan', 'Cliente1', 'Cliente2', 'Cliente3'], + ); + + expect(estado.partida, isNotNull); + expect(estado.partida!.jugadores.length, 4); + + // Verify host is in the list + final hostJugador = estado.partida!.jugadores + .where((j) => j.nombre == 'HostJuan') + .firstOrNull; + expect(hostJugador, isNotNull); + expect(hostJugador!.endpointId, isNull); + }, + ); + + test('should assign word to host local player', () { + estado.setHostJugador('HostJuan'); + + estado.crearPartida( + config: ConfigPartida( + modoMultimovil: true, + categoria: 'objetos', + numImpostores: 1, + pistaImpostor: false, + ), + nombresJugadores: ['HostJuan', 'Cliente1', 'Cliente2', 'Cliente3'], + ); + + final hostJugador = estado.partida!.jugadores + .where((j) => j.nombre == 'HostJuan') + .firstOrNull; + + // Host should receive a word if not impostor + expect(hostJugador, isNotNull); + if (!hostJugador!.esImpostor) { + expect(hostJugador.palabra, isNotNull); + expect(hostJugador.palabra!.isNotEmpty, isTrue); + } + }); + + test('should include host in impostor selection pool', () async { + // Run multiple times to increase chance of host being impostor + bool hostWasImpostorAtLeastOnce = false; + bool hostWasNormalAtLeastOnce = false; + + for (int i = 0; i < 20; i++) { + final estado2 = EstadoJuego(); + await estado2.cargarBanco(); + estado2.setHostJugador('HostJuan'); + + estado2.crearPartida( + config: ConfigPartida( + modoMultimovil: true, + categoria: 'objetos', + numImpostores: 1, + pistaImpostor: false, + ), + nombresJugadores: ['HostJuan', 'Cliente1', 'Cliente2', 'Cliente3'], + ); + + final hostJugador = estado2.partida!.jugadores + .where((j) => j.nombre == 'HostJuan') + .firstOrNull; + + if (hostJugador!.esImpostor) { + hostWasImpostorAtLeastOnce = true; + } else { + hostWasNormalAtLeastOnce = true; + } + estado2.dispose(); + + if (hostWasImpostorAtLeastOnce && hostWasNormalAtLeastOnce) break; + } + + // Host should have been impostor at least once and normal at least once + // (statistically likely with 20 iterations) + expect(hostWasImpostorAtLeastOnce, isTrue); + expect(hostWasNormalAtLeastOnce, isTrue); + }); + }); +} diff --git a/test/estado_juego_host_local_test.dart b/test/estado_juego_host_local_test.dart new file mode 100644 index 0000000..9a85fb3 --- /dev/null +++ b/test/estado_juego_host_local_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/estado/estado_juego.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('EstadoJuego host local', () { + late EstadoJuego estado; + + setUp(() { + estado = EstadoJuego(); + }); + + tearDown(() { + estado.dispose(); + }); + + test('should start with null hostLocal', () { + expect(estado.hostLocal, isNull); + }); + + test('should set host jugador correctly', () { + estado.setHostJugador('Juan'); + + expect(estado.hostLocal, isNotNull); + expect(estado.hostLocal!.nombre, 'Juan'); + expect(estado.hostLocal!.endpointId, isNull); + }); + + test('should update host jugador name', () { + estado.setHostJugador('Juan'); + expect(estado.hostLocal!.nombre, 'Juan'); + + estado.setHostJugador('Maria'); + expect(estado.hostLocal!.nombre, 'Maria'); + }); + }); +} diff --git a/test/servicio_nearby_user_pool_test.dart b/test/servicio_nearby_user_pool_test.dart new file mode 100644 index 0000000..e640715 --- /dev/null +++ b/test/servicio_nearby_user_pool_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/modelos/usuario.dart'; +import 'package:farolero/servicios/servicio_nearby.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ServicioNearby user pool', () { + late ServicioNearby servicio; + + setUp(() { + servicio = ServicioNearby(); + }); + + tearDown(() { + servicio.dispose(); + }); + + test('should start with empty user pool', () { + expect(servicio.usuarios, isEmpty); + }); + + test('should add user to pool', () { + final usuario = Usuario(id: 'user-1', nombre: 'Juan'); + servicio.agregarUsuario(usuario); + + expect(servicio.usuarios.length, 1); + expect(servicio.usuarios.first.nombre, 'Juan'); + }); + + test('should remove user from pool', () { + final usuario = Usuario(id: 'user-1', nombre: 'Juan'); + servicio.agregarUsuario(usuario); + expect(servicio.usuarios.length, 1); + + servicio.eliminarUsuario('user-1'); + expect(servicio.usuarios, isEmpty); + }); + + test('should synchronize users from list', () { + final usuarios = [ + Usuario(id: 'user-1', nombre: 'Juan'), + Usuario(id: 'user-2', nombre: 'Maria'), + ]; + servicio.sincronizarUsuarios(usuarios); + + expect(servicio.usuarios.length, 2); + expect(servicio.usuarios.map((u) => u.nombre).toList(), contains('Juan')); + expect( + servicio.usuarios.map((u) => u.nombre).toList(), + contains('Maria'), + ); + }); + + test('should get usuario by id', () { + final usuario = Usuario(id: 'user-1', nombre: 'Juan'); + servicio.agregarUsuario(usuario); + + final found = servicio.getUsuario('user-1'); + expect(found, isNotNull); + expect(found!.nombre, 'Juan'); + }); + + test('should return null for non-existent user', () { + final found = servicio.getUsuario('non-existent'); + expect(found, isNull); + }); + }); +} diff --git a/test/usuario_test.dart b/test/usuario_test.dart new file mode 100644 index 0000000..a794e7c --- /dev/null +++ b/test/usuario_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/modelos/usuario.dart'; + +void main() { + group('Usuario', () { + test('should create Usuario with id and nombre', () { + final usuario = Usuario(id: 'test-id', nombre: 'Juan'); + + expect(usuario.id, 'test-id'); + expect(usuario.nombre, 'Juan'); + }); + + test('should serialize to JSON correctly', () { + final usuario = Usuario(id: 'test-id', nombre: 'Juan'); + final json = usuario.toJson(); + + expect(json['id'], 'test-id'); + expect(json['nombre'], 'Juan'); + }); + + test('should deserialize from JSON correctly', () { + final json = {'id': 'test-id', 'nombre': 'Juan'}; + final usuario = Usuario.fromJson(json); + + expect(usuario.id, 'test-id'); + expect(usuario.nombre, 'Juan'); + }); + + test('should handle JSON with avatar field', () { + final json = {'id': 'test-id', 'nombre': 'Juan', 'avatar': '👤'}; + final usuario = Usuario.fromJson(json); + + expect(usuario.id, 'test-id'); + expect(usuario.nombre, 'Juan'); + }); + }); +}