Compare commits
16 Commits
a59a9a481e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0d4dc2ba | ||
|
|
50b050e678 | ||
|
|
5d3b3ef271 | ||
| c8e5cf25c5 | |||
| d850b66089 | |||
|
|
166b89a661 | ||
|
|
1cb2260298 | ||
| da9bd0cd4a | |||
| d600835105 | |||
|
|
a8d5b0f002 | ||
|
|
4a1abd0be0 | ||
| f3dcb99de1 | |||
|
|
f41fbc7dd9 | ||
|
|
e3c502c7df | ||
| 3f4ec2d20f | |||
|
|
1231b32c3c |
@@ -5,7 +5,6 @@ on:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
|
||||
|
||||
jobs:
|
||||
@@ -14,6 +13,10 @@ jobs:
|
||||
runs-on: [self-hosted, macos, arm64, flutter]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verificar Flutter
|
||||
run: |
|
||||
which flutter
|
||||
flutter --version
|
||||
- name: Obtener dependencias
|
||||
run: flutter pub get
|
||||
- name: Generar l10n
|
||||
@@ -28,6 +31,10 @@ jobs:
|
||||
if: ${{ gitea.ref == 'refs/heads/main' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verificar Flutter
|
||||
run: |
|
||||
which flutter
|
||||
flutter --version
|
||||
|
||||
- name: Fetch completo + Bump versión patch + commit
|
||||
run: |
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,3 +48,5 @@ build/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
|
||||
.atl/
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import '../modelos/jugador.dart';
|
||||
import '../modelos/partida.dart';
|
||||
import '../modelos/palabra.dart';
|
||||
import '../modelos/sala_multijugador.dart';
|
||||
import '../servicios/servicio_notas.dart';
|
||||
|
||||
/// Estado global del juego gestionado con Provider
|
||||
@@ -89,6 +90,61 @@ class EstadoJuego extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Crea una partida multi-dispositivo usando los usuarios seleccionados de la
|
||||
/// sala como jugadores reales. La identidad de jugador se conserva por id y
|
||||
/// cada jugador queda asociado al endpoint del cliente que lo controla.
|
||||
void crearPartidaDesdeSala({
|
||||
required ConfigPartida config,
|
||||
required EstadoSalaMultijugador sala,
|
||||
}) {
|
||||
if (_banco == null) return;
|
||||
final usuariosSeleccionados = sala.usuariosSeleccionados;
|
||||
if (usuariosSeleccionados.length < 3) return;
|
||||
|
||||
final palabra = _banco!.palabraAleatoria(config.categoria);
|
||||
final categoriaReal =
|
||||
_banco!.categoriaDepalabra(palabra) ?? config.categoria;
|
||||
|
||||
final jugadores = usuariosSeleccionados.map((usuario) {
|
||||
final clienteId = usuario.clienteIdSeleccionado;
|
||||
final endpointId = clienteId == null
|
||||
? null
|
||||
: sala.clientes[clienteId]?.endpointId;
|
||||
return Jugador(
|
||||
id: usuario.id,
|
||||
nombre: usuario.nombre,
|
||||
endpointId: endpointId,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final rng = Random.secure();
|
||||
final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3);
|
||||
final impostoresElegidos = <int>{};
|
||||
while (impostoresElegidos.length < numImpostores) {
|
||||
impostoresElegidos.add(rng.nextInt(jugadores.length));
|
||||
}
|
||||
for (final i in impostoresElegidos) {
|
||||
jugadores[i].esImpostor = true;
|
||||
}
|
||||
|
||||
for (final jugador in jugadores) {
|
||||
if (!jugador.esImpostor) {
|
||||
jugador.palabra = palabra;
|
||||
}
|
||||
}
|
||||
|
||||
_partida = Partida(
|
||||
config: config,
|
||||
jugadores: jugadores,
|
||||
palabraSecreta: palabra,
|
||||
categoriaReal: categoriaReal,
|
||||
);
|
||||
|
||||
_votos.clear();
|
||||
ServicioNotas.limpiarNotas();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Avanza a la fase de debate
|
||||
void iniciarDebate() {
|
||||
if (_partida == null) return;
|
||||
|
||||
117
lib/modelos/inicio_partida_multijugador.dart
Normal file
117
lib/modelos/inicio_partida_multijugador.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
class AsignacionJugador {
|
||||
final String jugadorId;
|
||||
final String nombre;
|
||||
final String clientId;
|
||||
final String? endpointId;
|
||||
|
||||
const AsignacionJugador({
|
||||
required this.jugadorId,
|
||||
required this.nombre,
|
||||
required this.clientId,
|
||||
required this.endpointId,
|
||||
});
|
||||
}
|
||||
|
||||
class JugadorInicioPartida {
|
||||
final String jugadorId;
|
||||
final String nombre;
|
||||
final bool esImpostor;
|
||||
final String? palabra;
|
||||
|
||||
const JugadorInicioPartida({
|
||||
required this.jugadorId,
|
||||
required this.nombre,
|
||||
required this.esImpostor,
|
||||
required this.palabra,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'jugadorId': jugadorId,
|
||||
'nombre': nombre,
|
||||
'esImpostor': esImpostor,
|
||||
if (palabra != null) 'palabra': palabra,
|
||||
};
|
||||
|
||||
factory JugadorInicioPartida.fromJson(Map<String, dynamic> json) {
|
||||
return JugadorInicioPartida(
|
||||
jugadorId: json['jugadorId'] as String,
|
||||
nombre: json['nombre'] as String,
|
||||
esImpostor: json['esImpostor'] as bool? ?? false,
|
||||
palabra: json['palabra'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InicioPartidaCliente {
|
||||
final String clientId;
|
||||
final String? endpointId;
|
||||
final String categoria;
|
||||
final List<JugadorInicioPartida> jugadores;
|
||||
|
||||
const InicioPartidaCliente({
|
||||
required this.clientId,
|
||||
required this.endpointId,
|
||||
required this.categoria,
|
||||
required this.jugadores,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'clientId': clientId,
|
||||
if (endpointId != null) 'endpointId': endpointId,
|
||||
'categoria': categoria,
|
||||
'jugadores': jugadores.map((jugador) => jugador.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory InicioPartidaCliente.fromJson(Map<String, dynamic> json) {
|
||||
return InicioPartidaCliente(
|
||||
clientId: json['clientId'] as String,
|
||||
endpointId: json['endpointId'] as String?,
|
||||
categoria: json['categoria'] as String,
|
||||
jugadores: (json['jugadores'] as List<dynamic>? ?? [])
|
||||
.map((jugadorJson) => JugadorInicioPartida.fromJson(
|
||||
jugadorJson as Map<String, dynamic>,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InicioPartidaMultijugador {
|
||||
static Map<String, InicioPartidaCliente> crearPayloadsPorCliente({
|
||||
required List<AsignacionJugador> asignaciones,
|
||||
required String palabraSecreta,
|
||||
required String categoria,
|
||||
required Map<String, bool> impostoresPorJugadorId,
|
||||
}) {
|
||||
final payloads = <String, InicioPartidaCliente>{};
|
||||
|
||||
for (final asignacion in asignaciones) {
|
||||
final esImpostor = impostoresPorJugadorId[asignacion.jugadorId] ?? false;
|
||||
final payloadActual = payloads[asignacion.clientId];
|
||||
final jugador = JugadorInicioPartida(
|
||||
jugadorId: asignacion.jugadorId,
|
||||
nombre: asignacion.nombre,
|
||||
esImpostor: esImpostor,
|
||||
palabra: esImpostor ? null : palabraSecreta,
|
||||
);
|
||||
|
||||
if (payloadActual == null) {
|
||||
payloads[asignacion.clientId] = InicioPartidaCliente(
|
||||
clientId: asignacion.clientId,
|
||||
endpointId: asignacion.endpointId,
|
||||
categoria: categoria,
|
||||
jugadores: [jugador],
|
||||
);
|
||||
} else {
|
||||
payloads[asignacion.clientId] = InicioPartidaCliente(
|
||||
clientId: payloadActual.clientId,
|
||||
endpointId: payloadActual.endpointId,
|
||||
categoria: payloadActual.categoria,
|
||||
jugadores: [...payloadActual.jugadores, jugador],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
}
|
||||
294
lib/modelos/sala_multijugador.dart
Normal file
294
lib/modelos/sala_multijugador.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'usuario.dart';
|
||||
|
||||
enum FaseSalaMultijugador { lobby, enPartida, finalizada }
|
||||
|
||||
class ResultadoOperacionSala {
|
||||
final bool exitoso;
|
||||
final String? codigo;
|
||||
final String? mensaje;
|
||||
|
||||
const ResultadoOperacionSala._({
|
||||
required this.exitoso,
|
||||
this.codigo,
|
||||
this.mensaje,
|
||||
});
|
||||
|
||||
const ResultadoOperacionSala.ok([String? mensaje])
|
||||
: this._(exitoso: true, mensaje: mensaje);
|
||||
|
||||
const ResultadoOperacionSala.error(String codigo, [String? mensaje])
|
||||
: this._(exitoso: false, codigo: codigo, mensaje: mensaje);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'exitoso': exitoso,
|
||||
if (codigo != null) 'codigo': codigo,
|
||||
if (mensaje != null) 'mensaje': mensaje,
|
||||
};
|
||||
}
|
||||
|
||||
class ClienteSala {
|
||||
final String clientId;
|
||||
final String? endpointId;
|
||||
final String nombre;
|
||||
final bool esHost;
|
||||
final bool conectado;
|
||||
|
||||
const ClienteSala({
|
||||
required this.clientId,
|
||||
this.endpointId,
|
||||
required this.nombre,
|
||||
this.esHost = false,
|
||||
this.conectado = true,
|
||||
});
|
||||
|
||||
ClienteSala copiar({
|
||||
String? clientId,
|
||||
String? endpointId,
|
||||
String? nombre,
|
||||
bool? esHost,
|
||||
bool? conectado,
|
||||
}) {
|
||||
return ClienteSala(
|
||||
clientId: clientId ?? this.clientId,
|
||||
endpointId: endpointId ?? this.endpointId,
|
||||
nombre: nombre ?? this.nombre,
|
||||
esHost: esHost ?? this.esHost,
|
||||
conectado: conectado ?? this.conectado,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'clientId': clientId,
|
||||
if (endpointId != null) 'endpointId': endpointId,
|
||||
'nombre': nombre,
|
||||
'esHost': esHost,
|
||||
'conectado': conectado,
|
||||
};
|
||||
|
||||
factory ClienteSala.fromJson(Map<String, dynamic> json) => ClienteSala(
|
||||
clientId: json['clientId'] as String,
|
||||
endpointId: json['endpointId'] as String?,
|
||||
nombre: json['nombre'] as String,
|
||||
esHost: json['esHost'] as bool? ?? false,
|
||||
conectado: json['conectado'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
class EstadoSalaMultijugador {
|
||||
final String roomId;
|
||||
final String nombreSala;
|
||||
final String hostClientId;
|
||||
FaseSalaMultijugador fase;
|
||||
final Map<String, ClienteSala> clientes;
|
||||
final Map<String, Usuario> usuarios;
|
||||
|
||||
EstadoSalaMultijugador({
|
||||
required this.roomId,
|
||||
required this.nombreSala,
|
||||
required this.hostClientId,
|
||||
this.fase = FaseSalaMultijugador.lobby,
|
||||
Map<String, ClienteSala>? clientes,
|
||||
Map<String, Usuario>? usuarios,
|
||||
}) : clientes = clientes ?? {},
|
||||
usuarios = usuarios ?? {};
|
||||
|
||||
factory EstadoSalaMultijugador.crear({
|
||||
required String roomId,
|
||||
required String nombreSala,
|
||||
required String hostClientId,
|
||||
required String hostNombre,
|
||||
}) {
|
||||
final sala = EstadoSalaMultijugador(
|
||||
roomId: roomId,
|
||||
nombreSala: nombreSala,
|
||||
hostClientId: hostClientId,
|
||||
);
|
||||
sala.registrarCliente(
|
||||
ClienteSala(
|
||||
clientId: hostClientId,
|
||||
nombre: hostNombre,
|
||||
esHost: true,
|
||||
),
|
||||
);
|
||||
return sala;
|
||||
}
|
||||
|
||||
List<Usuario> get usuariosSeleccionados =>
|
||||
usuarios.values.where((usuario) => usuario.estaSeleccionado).toList();
|
||||
|
||||
List<Usuario> get usuariosDisponibles =>
|
||||
usuarios.values.where((usuario) => usuario.estaDisponible).toList();
|
||||
|
||||
int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length;
|
||||
|
||||
List<Usuario> usuariosPorCliente(String clientId) {
|
||||
return usuarios.values
|
||||
.where((usuario) => usuario.clienteIdSeleccionado == clientId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
ClienteSala? clientePorEndpoint(String endpointId) {
|
||||
for (final cliente in clientes.values) {
|
||||
if (cliente.endpointId == endpointId) return cliente;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ResultadoOperacionSala registrarCliente(ClienteSala cliente) {
|
||||
clientes[cliente.clientId] = cliente;
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala crearUsuario(Usuario usuario) {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
if (usuarios.containsKey(usuario.id)) {
|
||||
return const ResultadoOperacionSala.error('usuario_duplicado');
|
||||
}
|
||||
final nombreNormalizado = usuario.nombre.trim().toLowerCase();
|
||||
final nombreExiste = usuarios.values.any(
|
||||
(u) => u.nombre.trim().toLowerCase() == nombreNormalizado,
|
||||
);
|
||||
if (nombreExiste) {
|
||||
return const ResultadoOperacionSala.error('nombre_duplicado');
|
||||
}
|
||||
usuarios[usuario.id] = usuario;
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala seleccionarUsuario({
|
||||
required String usuarioId,
|
||||
required String clienteId,
|
||||
}) {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
final cliente = clientes[clienteId];
|
||||
if (cliente == null || !cliente.conectado) {
|
||||
return const ResultadoOperacionSala.error('cliente_no_disponible');
|
||||
}
|
||||
final usuario = usuarios[usuarioId];
|
||||
if (usuario == null) {
|
||||
return const ResultadoOperacionSala.error('usuario_no_existe');
|
||||
}
|
||||
if (usuario.clienteIdSeleccionado == clienteId) {
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
if (usuario.clienteIdSeleccionado != null) {
|
||||
return const ResultadoOperacionSala.error('usuario_ya_seleccionado');
|
||||
}
|
||||
usuarios[usuarioId] = usuario.copiar(clienteIdSeleccionado: clienteId);
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala liberarUsuario({
|
||||
required String usuarioId,
|
||||
required String solicitanteClientId,
|
||||
}) {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
final usuario = usuarios[usuarioId];
|
||||
if (usuario == null) {
|
||||
return const ResultadoOperacionSala.error('usuario_no_existe');
|
||||
}
|
||||
final solicitante = clientes[solicitanteClientId];
|
||||
final puedeLiberar =
|
||||
usuario.clienteIdSeleccionado == solicitanteClientId ||
|
||||
(solicitante?.esHost ?? false);
|
||||
if (!puedeLiberar) {
|
||||
return const ResultadoOperacionSala.error('usuario_de_otro_cliente');
|
||||
}
|
||||
usuarios[usuarioId] = usuario.copiar(liberarSeleccion: true);
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala eliminarUsuario({
|
||||
required String usuarioId,
|
||||
required String solicitanteClientId,
|
||||
}) {
|
||||
final solicitante = clientes[solicitanteClientId];
|
||||
if (!(solicitante?.esHost ?? false)) {
|
||||
return const ResultadoOperacionSala.error('solo_host');
|
||||
}
|
||||
final usuario = usuarios[usuarioId];
|
||||
if (usuario == null) {
|
||||
return const ResultadoOperacionSala.error('usuario_no_existe');
|
||||
}
|
||||
if (usuario.estaSeleccionado) {
|
||||
return const ResultadoOperacionSala.error('usuario_seleccionado');
|
||||
}
|
||||
usuarios.remove(usuarioId);
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
void desconectarCliente(String clientId) {
|
||||
final cliente = clientes[clientId];
|
||||
if (cliente == null) return;
|
||||
clientes[clientId] = cliente.copiar(conectado: false);
|
||||
if (fase != FaseSalaMultijugador.lobby) return;
|
||||
for (final entry in usuarios.entries.toList()) {
|
||||
if (entry.value.clienteIdSeleccionado == clientId) {
|
||||
usuarios[entry.key] = entry.value.copiar(liberarSeleccion: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ResultadoOperacionSala validarInicio() {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
}
|
||||
if (cantidadUsuariosSeleccionados < 3) {
|
||||
return const ResultadoOperacionSala.error('faltan_jugadores');
|
||||
}
|
||||
if (usuariosPorCliente(hostClientId).isEmpty) {
|
||||
return const ResultadoOperacionSala.error('host_sin_usuario');
|
||||
}
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
ResultadoOperacionSala iniciarPartida() {
|
||||
final validacion = validarInicio();
|
||||
if (!validacion.exitoso) return validacion;
|
||||
fase = FaseSalaMultijugador.enPartida;
|
||||
return const ResultadoOperacionSala.ok();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'roomId': roomId,
|
||||
'nombreSala': nombreSala,
|
||||
'hostClientId': hostClientId,
|
||||
'fase': fase.name,
|
||||
'clientes': clientes.values.map((cliente) => cliente.toJson()).toList(),
|
||||
'usuarios': usuarios.values.map((usuario) => usuario.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory EstadoSalaMultijugador.fromJson(Map<String, dynamic> json) {
|
||||
final clientes = <String, ClienteSala>{};
|
||||
for (final clienteJson in json['clientes'] as List<dynamic>? ?? []) {
|
||||
final cliente = ClienteSala.fromJson(
|
||||
clienteJson as Map<String, dynamic>,
|
||||
);
|
||||
clientes[cliente.clientId] = cliente;
|
||||
}
|
||||
|
||||
final usuarios = <String, Usuario>{};
|
||||
for (final usuarioJson in json['usuarios'] as List<dynamic>? ?? []) {
|
||||
final usuario = Usuario.fromJson(usuarioJson as Map<String, dynamic>);
|
||||
usuarios[usuario.id] = usuario;
|
||||
}
|
||||
|
||||
return EstadoSalaMultijugador(
|
||||
roomId: json['roomId'] as String,
|
||||
nombreSala: json['nombreSala'] as String,
|
||||
hostClientId: json['hostClientId'] as String,
|
||||
fase: FaseSalaMultijugador.values.firstWhere(
|
||||
(fase) => fase.name == json['fase'],
|
||||
orElse: () => FaseSalaMultijugador.lobby,
|
||||
),
|
||||
clientes: clientes,
|
||||
usuarios: usuarios,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,53 @@ class Usuario {
|
||||
final String id;
|
||||
final String nombre;
|
||||
final String? avatar;
|
||||
final String? creadoPorClienteId;
|
||||
final String? clienteIdSeleccionado;
|
||||
|
||||
Usuario({required this.id, required this.nombre, this.avatar});
|
||||
Usuario({
|
||||
required this.id,
|
||||
required this.nombre,
|
||||
this.avatar,
|
||||
this.creadoPorClienteId,
|
||||
this.clienteIdSeleccionado,
|
||||
});
|
||||
|
||||
bool get estaSeleccionado => clienteIdSeleccionado != null;
|
||||
bool get estaDisponible => clienteIdSeleccionado == null;
|
||||
|
||||
Usuario copiar({
|
||||
String? id,
|
||||
String? nombre,
|
||||
String? avatar,
|
||||
String? creadoPorClienteId,
|
||||
String? clienteIdSeleccionado,
|
||||
bool liberarSeleccion = false,
|
||||
}) {
|
||||
return Usuario(
|
||||
id: id ?? this.id,
|
||||
nombre: nombre ?? this.nombre,
|
||||
avatar: avatar ?? this.avatar,
|
||||
creadoPorClienteId: creadoPorClienteId ?? this.creadoPorClienteId,
|
||||
clienteIdSeleccionado: liberarSeleccion
|
||||
? null
|
||||
: (clienteIdSeleccionado ?? this.clienteIdSeleccionado),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'nombre': nombre,
|
||||
if (avatar != null) 'avatar': avatar,
|
||||
if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId,
|
||||
if (clienteIdSeleccionado != null)
|
||||
'clienteIdSeleccionado': clienteIdSeleccionado,
|
||||
};
|
||||
|
||||
factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
|
||||
id: json['id'] as String,
|
||||
nombre: json['nombre'] as String,
|
||||
avatar: json['avatar'] as String?,
|
||||
creadoPorClienteId: json['creadoPorClienteId'] as String?,
|
||||
clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../estado/estado_juego.dart';
|
||||
import '../modelos/inicio_partida_multijugador.dart';
|
||||
import '../modelos/palabra.dart';
|
||||
import '../modelos/partida.dart';
|
||||
import '../modelos/usuario.dart';
|
||||
@@ -153,15 +154,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
onIniciar: () {
|
||||
// Cuando el host toca "Iniciar" con suficientes jugadores
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final sala = nearby.estadoSala;
|
||||
if (sala == null) return;
|
||||
final validacion = sala.iniciarPartida();
|
||||
if (!validacion.exitoso) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'No se puede iniciar: ${validacion.codigo ?? "sala inválida"}',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set host local player first (required for host-included game)
|
||||
estado.setHostJugador(nombre.trim());
|
||||
|
||||
final jugadoresMulti = [
|
||||
nombre.trim(),
|
||||
...nearby.jugadores.map((j) => j.nombre),
|
||||
];
|
||||
estado.crearPartida(
|
||||
estado.crearPartidaDesdeSala(
|
||||
config: ConfigPartida(
|
||||
modoMultimovil: true,
|
||||
categoria: _categoria,
|
||||
@@ -169,24 +176,41 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
pistaImpostor: _pistaImpostor,
|
||||
tiempoDebateSegundos: _tiempoDebate,
|
||||
),
|
||||
nombresJugadores: jugadoresMulti,
|
||||
sala: sala,
|
||||
);
|
||||
|
||||
// Enviar palabras a cada jugador via Nearby
|
||||
final partida = estado.partida!;
|
||||
final impostores = <String, bool>{};
|
||||
for (int i = 0; i < nearby.jugadores.length; i++) {
|
||||
final jugadorNearby = nearby.jugadores[i];
|
||||
// El jugador [0] es el host, los de nearby son [1..n]
|
||||
final jugadorPartida = partida.jugadores[i + 1];
|
||||
impostores[jugadorNearby.endpointId] =
|
||||
jugadorPartida.esImpostor;
|
||||
}
|
||||
final asignaciones = partida.jugadores.map((jugador) {
|
||||
final usuarioSala = sala.usuarios[jugador.id];
|
||||
final clientId = usuarioSala?.clienteIdSeleccionado;
|
||||
final cliente = clientId == null ? null : sala.clientes[clientId];
|
||||
return AsignacionJugador(
|
||||
jugadorId: jugador.id,
|
||||
nombre: jugador.nombre,
|
||||
clientId: clientId ?? sala.hostClientId,
|
||||
endpointId: cliente?.endpointId,
|
||||
);
|
||||
}).toList();
|
||||
final impostores = {
|
||||
for (final jugador in partida.jugadores)
|
||||
jugador.id: jugador.esImpostor,
|
||||
};
|
||||
final jugadoresTodos = partida.jugadores
|
||||
.map(
|
||||
(jugador) => {
|
||||
'id': jugador.id,
|
||||
'nombre': jugador.nombre,
|
||||
'eliminado': jugador.eliminado,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
nearby.enviarInicioPartida(
|
||||
nearby.enviarInicioPartidaMulti(
|
||||
asignaciones: asignaciones,
|
||||
palabraSecreta: partida.palabraSecreta,
|
||||
categoria: _categoria,
|
||||
impostores: impostores,
|
||||
impostoresPorJugadorId: impostores,
|
||||
jugadoresTodos: jugadoresTodos,
|
||||
);
|
||||
|
||||
Navigator.pushReplacement(
|
||||
@@ -230,7 +254,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
value: seleccionado,
|
||||
initialValue: seleccionado,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.person),
|
||||
hintText: l10n.selectProfile,
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../estado/estado_juego.dart';
|
||||
import '../modelos/inicio_partida_multijugador.dart';
|
||||
import '../modelos/partida.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
|
||||
import 'pantalla_votacion_cliente.dart';
|
||||
import 'pantalla_palabras_cliente.dart';
|
||||
|
||||
class PantallaGestorHost extends StatefulWidget {
|
||||
final VoidCallback onPartidaFin;
|
||||
@@ -52,8 +54,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
setState(() => _clientesListos[endpointId] = true);
|
||||
} else if (mensaje.tipo == TipoMensaje.voto) {
|
||||
final votanteId = mensaje.datos['votanteId'] as String?;
|
||||
final votoId = mensaje.datos['votoporId'] as String?;
|
||||
final votoId =
|
||||
mensaje.datos['votadoId'] as String? ??
|
||||
mensaje.datos['votoporId'] as String?;
|
||||
if (votanteId != null && votoId != null) {
|
||||
context.read<EstadoJuego>().registrarVoto(votanteId, votoId);
|
||||
setState(() => _votosRecibidos[votanteId] = votoId);
|
||||
}
|
||||
}
|
||||
@@ -117,9 +122,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
);
|
||||
}
|
||||
|
||||
final numJugadores = partida.jugadores.length + 1;
|
||||
final todosListos = _clientesListos.length >= numJugadores - 1;
|
||||
final todosVotaron = _votosRecibidos.length >= numJugadores - 1;
|
||||
final todosListos = _clientesListos.length >= nearby.jugadores.length;
|
||||
final todosVotaron = estado.todosHanVotado();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -253,6 +257,21 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Botón para que el host vea su palabra
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _mostrarPalabraHost(context),
|
||||
icon: const Icon(Icons.visibility),
|
||||
label: Text(l10n.seeYourWord),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TemaApp.colorNaranja,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (todosListos)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -278,6 +297,62 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
);
|
||||
}
|
||||
|
||||
void _mostrarPalabraHost(BuildContext context) {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final sala = context.read<ServicioNearby>().estadoSala;
|
||||
final partida = estado.partida;
|
||||
if (partida == null || sala == null) return;
|
||||
|
||||
final jugadoresHost = sala
|
||||
.usuariosPorCliente(sala.hostClientId)
|
||||
.where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
|
||||
.map((usuario) {
|
||||
final jugador = partida.jugadores.firstWhere(
|
||||
(j) => j.id == usuario.id,
|
||||
);
|
||||
return JugadorInicioPartida(
|
||||
jugadorId: jugador.id,
|
||||
nombre: jugador.nombre,
|
||||
esImpostor: jugador.esImpostor,
|
||||
palabra: jugador.palabra,
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (jugadoresHost.length > 1) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaPalabrasCliente(
|
||||
jugadores: jugadoresHost,
|
||||
pistaCategoria: partida.config.pistaImpostor
|
||||
? partida.categoriaReal
|
||||
: null,
|
||||
onTodosVistos: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final hostLocal = jugadoresHost.isNotEmpty
|
||||
? partida.jugadores.firstWhere((j) => j.id == jugadoresHost.first.jugadorId)
|
||||
: partida.jugadores.first;
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _PantallaRevelarPalabraHost(
|
||||
nombre: hostLocal.nombre,
|
||||
esImpostor: hostLocal.esImpostor,
|
||||
palabra: partida.palabraSecreta,
|
||||
pistaActiva: partida.config.pistaImpostor,
|
||||
categoria: partida.categoriaReal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFaseDebate(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
@@ -354,6 +429,12 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
bool todosVotaron,
|
||||
ServicioNearby nearby,
|
||||
) {
|
||||
final estado = context.watch<EstadoJuego>();
|
||||
final partida = estado.partida!;
|
||||
final totalVotos = partida.jugadoresActivos.length;
|
||||
final votosEmitidos = estado.votos.length;
|
||||
final progreso = totalVotos == 0 ? 0.0 : votosEmitidos / totalVotos;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -371,19 +452,12 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.votesProgress(
|
||||
_votosRecibidos.length,
|
||||
nearby.jugadores.length + 1,
|
||||
),
|
||||
),
|
||||
Text(l10n.votesProgress(votosEmitidos, totalVotos)),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value:
|
||||
_votosRecibidos.length /
|
||||
(nearby.jugadores.length + 1),
|
||||
value: progreso.clamp(0.0, 1.0).toDouble(),
|
||||
backgroundColor: TemaApp.colorSuperficie,
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
TemaApp.colorAcento,
|
||||
@@ -394,6 +468,19 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _hostYaVoto(context) ? null : () => _abrirVotacionHost(context),
|
||||
icon: const Icon(Icons.how_to_vote),
|
||||
label: Text(
|
||||
_hostYaVoto(context)
|
||||
? 'Votos del host registrados'
|
||||
: 'Votar por los jugadores de este m?vil',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.playersVoted,
|
||||
@@ -402,15 +489,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: nearby.jugadores.length + 1,
|
||||
itemCount: partida.jugadoresActivos.length,
|
||||
itemBuilder: (context, index) {
|
||||
final esHost = index == 0;
|
||||
final nombre = esHost
|
||||
? (nearby.miNombre ?? 'Host')
|
||||
: nearby.jugadores[index - 1].nombre;
|
||||
final haVotado =
|
||||
esHost || _votosRecibidos.containsKey(nombre);
|
||||
return _buildJugadorTile(nombre, esHost, haVotado);
|
||||
final jugador = partida.jugadoresActivos[index];
|
||||
final haVotado = estado.votos.containsKey(jugador.id);
|
||||
return _buildJugadorTile(jugador.nombre, false, haVotado);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -439,6 +522,51 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _hostYaVoto(BuildContext context) {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final sala = context.read<ServicioNearby>().estadoSala;
|
||||
if (sala == null || estado.partida == null) return false;
|
||||
final hostIds = sala.usuariosPorCliente(sala.hostClientId).map((u) => u.id);
|
||||
return hostIds.every((id) => estado.votos.containsKey(id));
|
||||
}
|
||||
|
||||
void _abrirVotacionHost(BuildContext context) {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final sala = context.read<ServicioNearby>().estadoSala;
|
||||
final partida = estado.partida;
|
||||
if (sala == null || partida == null) return;
|
||||
|
||||
final jugadoresHost = sala.usuariosPorCliente(sala.hostClientId)
|
||||
.where((usuario) => partida.jugadoresActivos.any((j) => j.id == usuario.id))
|
||||
.map(
|
||||
(usuario) => JugadorInicioPartida(
|
||||
jugadorId: usuario.id,
|
||||
nombre: usuario.nombre,
|
||||
esImpostor: partida.jugadores.firstWhere((j) => j.id == usuario.id).esImpostor,
|
||||
palabra: partida.jugadores.firstWhere((j) => j.id == usuario.id).palabra,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaVotacionCliente(
|
||||
jugadores: partida.jugadoresActivos,
|
||||
jugadoresControlados: jugadoresHost,
|
||||
onVotos: (votos) {
|
||||
for (final entry in votos.entries) {
|
||||
estado.registrarVoto(entry.key, entry.value);
|
||||
_votosRecibidos[entry.key] = entry.value;
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJugadorTile(String nombre, bool esHost, bool listo) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@@ -451,7 +579,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 18)),
|
||||
Text(
|
||||
esHost ? 'Host' : 'Cliente',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(nombre)),
|
||||
if (listo)
|
||||
@@ -512,3 +643,154 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PantallaRevelarPalabraHost extends StatefulWidget {
|
||||
final String nombre;
|
||||
final bool esImpostor;
|
||||
final String palabra;
|
||||
final bool pistaActiva;
|
||||
final String categoria;
|
||||
|
||||
const _PantallaRevelarPalabraHost({
|
||||
required this.nombre,
|
||||
required this.esImpostor,
|
||||
required this.palabra,
|
||||
required this.pistaActiva,
|
||||
required this.categoria,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PantallaRevelarPalabraHost> createState() =>
|
||||
_PantallaRevelarPalabraHostState();
|
||||
}
|
||||
|
||||
class _PantallaRevelarPalabraHostState
|
||||
extends State<_PantallaRevelarPalabraHost> {
|
||||
bool _manteniendo = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.nombre)),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.nombre,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: _manteniendo
|
||||
? (widget.esImpostor
|
||||
? TemaApp.colorAcento.withValues(alpha: 0.3)
|
||||
: TemaApp.colorVerde.withValues(alpha: 0.3))
|
||||
: TemaApp.colorTarjeta,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: _manteniendo
|
||||
? (widget.esImpostor
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorVerde)
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: _manteniendo
|
||||
? Column(
|
||||
children: [
|
||||
Text(
|
||||
widget.esImpostor ? 'Impostor' : 'Ciudadano',
|
||||
style: const TextStyle(fontSize: 48),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.esImpostor
|
||||
? l10n.youAreImpostor
|
||||
: l10n.yourWordIs,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(
|
||||
color: widget.esImpostor
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorVerde,
|
||||
),
|
||||
),
|
||||
if (!widget.esImpostor) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.palabra,
|
||||
style: Theme.of(context).textTheme.headlineLarge
|
||||
?.copyWith(fontSize: 32, color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (widget.esImpostor && widget.pistaActiva) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Categoria: ${widget.categoria}',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: TemaApp.colorNaranja),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
const Text('Candado', style: TextStyle(fontSize: 48)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.holdToSeeWord,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.makeSureNoOneLooks,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GestureDetector(
|
||||
onLongPressStart: (_) => setState(() => _manteniendo = true),
|
||||
onLongPressEnd: (_) => setState(() => _manteniendo = false),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: _manteniendo
|
||||
? [TemaApp.colorNaranja, TemaApp.colorAcento]
|
||||
: [TemaApp.colorAcento, TemaApp.colorAcento],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_manteniendo ? l10n.showingWord : l10n.holdToSee,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
@@ -6,7 +6,7 @@ import '../modelos/usuario.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
|
||||
/// Pantalla de lobby del host: muestra QR y lista de jugadores conectados
|
||||
/// Lobby del host. El host es autoridad de sala y también cliente local.
|
||||
class PantallaLobbyHost extends StatefulWidget {
|
||||
final String nombreSala;
|
||||
final VoidCallback onIniciar;
|
||||
@@ -23,14 +23,16 @@ class PantallaLobbyHost extends StatefulWidget {
|
||||
|
||||
class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
|
||||
bool _iniciando = false;
|
||||
String? _perfilSeleccionado;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final nearby = context.watch<ServicioNearby>();
|
||||
final jugadores = nearby.jugadores;
|
||||
final totalJugadores = jugadores.length + 1; // +1 host
|
||||
final sala = nearby.estadoSala;
|
||||
final usuarios = nearby.usuarios;
|
||||
final seleccionados = usuarios.where((u) => u.estaSeleccionado).length;
|
||||
final validacionInicio = sala?.validarInicio();
|
||||
final puedeIniciar = validacionInicio?.exitoso ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -47,7 +49,6 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// QR Code
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -57,174 +58,66 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
|
||||
child: QrImageView(
|
||||
data: nearby.generarDatosQR(widget.nombreSala),
|
||||
version: QrVersions.auto,
|
||||
size: 180,
|
||||
size: 160,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.scanToJoin,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(l10n.scanToJoin),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Lista de jugadores
|
||||
_buildResumenSala(context, seleccionados, nearby.jugadores.length),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.connectedPlayers,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: totalJugadores >= 3
|
||||
? TemaApp.colorVerde.withValues(alpha: 0.2)
|
||||
: TemaApp.colorNaranja.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$totalJugadores',
|
||||
style: TextStyle(
|
||||
color: totalJugadores >= 3
|
||||
? TemaApp.colorVerde
|
||||
: TemaApp.colorNaranja,
|
||||
fontWeight: FontWeight.bold,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Usuarios de la partida',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => _crearNuevoUsuario(context),
|
||||
icon: const Icon(Icons.person_add),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: usuarios.isEmpty
|
||||
? Center(child: Text(l10n.waitingForPlayers))
|
||||
: ListView.builder(
|
||||
itemCount: usuarios.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildUsuarioTile(context, usuarios[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Host (yo)
|
||||
_buildJugadorTile(
|
||||
nombre: nearby.miNombre ?? 'Host',
|
||||
esHost: true,
|
||||
),
|
||||
|
||||
// Jugadores conectados
|
||||
Expanded(
|
||||
child: jugadores.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'📱',
|
||||
style: TextStyle(fontSize: 48),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.waitingForPlayers,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: jugadores.length,
|
||||
itemBuilder: (context, index) {
|
||||
final j = jugadores[index];
|
||||
return _buildJugadorTile(nombre: j.nombre);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Botón iniciar
|
||||
if (totalJugadores < 3)
|
||||
Text(
|
||||
l10n.needMorePlayers(3 - totalJugadores),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_perfilSeleccionado == null)
|
||||
if (!puedeIniciar)
|
||||
Text(
|
||||
l10n.selectProfile,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja),
|
||||
_mensajeValidacion(validacionInicio?.codigo),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: TemaApp.colorNaranja),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed:
|
||||
totalJugadores >= 3 &&
|
||||
_perfilSeleccionado != null &&
|
||||
!_iniciando
|
||||
onPressed: puedeIniciar && !_iniciando
|
||||
? () {
|
||||
setState(() => _iniciando = true);
|
||||
widget.onIniciar();
|
||||
@@ -240,45 +133,129 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJugadorTile({required String nombre, bool esHost = false}) {
|
||||
Widget _buildResumenSala(
|
||||
BuildContext context,
|
||||
int seleccionados,
|
||||
int clientesRemotos,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStat(
|
||||
context,
|
||||
icon: Icons.groups,
|
||||
label: 'Jugadores seleccionados',
|
||||
value: '$seleccionados',
|
||||
ok: seleccionados >= 3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildStat(
|
||||
context,
|
||||
icon: Icons.devices,
|
||||
label: 'Móviles conectados',
|
||||
value: '${clientesRemotos + 1}',
|
||||
ok: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStat(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
required bool ok,
|
||||
}) {
|
||||
final color = ok ? TemaApp.colorVerde : TemaApp.colorNaranja;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: TemaApp.colorTarjeta,
|
||||
color: color.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: esHost
|
||||
? Border.all(color: TemaApp.colorAcento.withValues(alpha: 0.5))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 12),
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(nombre, style: Theme.of(context).textTheme.titleMedium),
|
||||
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
if (esHost)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: TemaApp.colorAcento.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'HOST',
|
||||
style: TextStyle(
|
||||
color: TemaApp.colorAcento,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUsuarioTile(BuildContext context, Usuario usuario) {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
final miClientId = nearby.miClientId;
|
||||
final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId;
|
||||
final seleccionadoPorOtro =
|
||||
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: seleccionadoPorMi
|
||||
? TemaApp.colorVerde
|
||||
: seleccionadoPorOtro
|
||||
? TemaApp.colorNaranja
|
||||
: TemaApp.colorTarjeta,
|
||||
child: Text(usuario.avatar ?? '👤'),
|
||||
),
|
||||
title: Text(usuario.nombre),
|
||||
subtitle: Text(
|
||||
seleccionadoPorMi
|
||||
? 'Seleccionado por este móvil'
|
||||
: seleccionadoPorOtro
|
||||
? 'Seleccionado por otro cliente'
|
||||
: 'Disponible',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (seleccionadoPorMi)
|
||||
IconButton(
|
||||
tooltip: 'Liberar',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => nearby.liberarUsuarioSala(usuario.id),
|
||||
)
|
||||
else if (!seleccionadoPorOtro)
|
||||
IconButton(
|
||||
tooltip: 'Seleccionar',
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
onPressed: () => nearby.seleccionarUsuarioSala(usuario.id),
|
||||
),
|
||||
if (!usuario.estaSeleccionado)
|
||||
IconButton(
|
||||
tooltip: 'Eliminar',
|
||||
icon: const Icon(Icons.delete_outline, color: TemaApp.colorAcento),
|
||||
onPressed: () => nearby.eliminarUsuarioSala(usuario.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _mensajeValidacion(String? codigo) {
|
||||
switch (codigo) {
|
||||
case 'faltan_jugadores':
|
||||
return 'Seleccioná al menos 3 usuarios para iniciar.';
|
||||
case 'host_sin_usuario':
|
||||
return 'El móvil servidor debe seleccionar al menos un usuario.';
|
||||
case 'sala_cerrada':
|
||||
return 'La sala ya no está en lobby.';
|
||||
default:
|
||||
return 'Completá la selección de usuarios para iniciar.';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _crearNuevoUsuario(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final controller = TextEditingController();
|
||||
@@ -312,12 +289,7 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
|
||||
);
|
||||
|
||||
if (nombre != null && nombre.trim().isNotEmpty) {
|
||||
final nuevoUsuario = Usuario(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
nombre: nombre.trim(),
|
||||
);
|
||||
nearby.agregarUsuario(nuevoUsuario);
|
||||
setState(() => _perfilSeleccionado = nombre.trim());
|
||||
await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
lib/pantallas/pantalla_palabras_cliente.dart
Normal file
128
lib/pantallas/pantalla_palabras_cliente.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||
import 'package:farolero/tema/tema_app.dart';
|
||||
|
||||
/// Reveal secuencial para clientes que manejan uno o varios jugadores.
|
||||
class PantallaPalabrasCliente extends StatefulWidget {
|
||||
final List<JugadorInicioPartida> jugadores;
|
||||
final String? pistaCategoria;
|
||||
final VoidCallback onTodosVistos;
|
||||
|
||||
const PantallaPalabrasCliente({
|
||||
super.key,
|
||||
required this.jugadores,
|
||||
this.pistaCategoria,
|
||||
required this.onTodosVistos,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PantallaPalabrasCliente> createState() => _PantallaPalabrasClienteState();
|
||||
}
|
||||
|
||||
class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
|
||||
int _indice = 0;
|
||||
bool _visible = false;
|
||||
|
||||
JugadorInicioPartida get _actual => widget.jugadores[_indice];
|
||||
bool get _esUltimo => _indice == widget.jugadores.length - 1;
|
||||
|
||||
void _continuar() {
|
||||
if (_esUltimo) {
|
||||
widget.onTodosVistos();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_indice++;
|
||||
_visible = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final actual = _actual;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: TemaApp.colorFondo,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Jugador ${_indice + 1} de ${widget.jugadores.length}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
actual.nombre,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _visible = !_visible),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 48,
|
||||
horizontal: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _visible ? TemaApp.colorAcento : TemaApp.colorTarjeta,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
_visible ? Icons.visibility : Icons.visibility_off,
|
||||
color: _visible ? Colors.white : TemaApp.colorTextoSecundario,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_visible
|
||||
? (actual.esImpostor
|
||||
? l10n.youAreImpostor
|
||||
: actual.palabra ?? '')
|
||||
: '???',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _visible
|
||||
? Colors.white
|
||||
: TemaApp.colorTextoSecundario,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_visible && actual.esImpostor && widget.pistaCategoria != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.clueIs(widget.pistaCategoria!),
|
||||
style: const TextStyle(color: TemaApp.colorNaranja),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _continuar,
|
||||
icon: Icon(_esUltimo ? Icons.check : Icons.arrow_forward),
|
||||
label: Text(_esUltimo ? l10n.iveSeenIt : 'Siguiente jugador'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ 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/inicio_partida_multijugador.dart';
|
||||
import '../modelos/usuario.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../servicios/servicio_permisos.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_palabra_cliente.dart';
|
||||
import 'pantalla_palabras_cliente.dart';
|
||||
import 'pantalla_debate_cliente.dart';
|
||||
import 'pantalla_votacion_cliente.dart';
|
||||
|
||||
@@ -36,6 +38,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
bool _esImpostor = false;
|
||||
String? _pistaCategoria;
|
||||
final List<Jugador> _jugadores = [];
|
||||
final List<JugadorInicioPartida> _jugadoresControlados = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -51,13 +54,38 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
nearby.onMensaje((endpointId, mensaje) {
|
||||
if (mensaje.tipo == TipoMensaje.partidaInicio) {
|
||||
// El host ha iniciado la partida — nos ha enviado nuestra palabra
|
||||
final jugadoresData = mensaje.datos['jugadores'] as List<dynamic>?;
|
||||
final jugadoresTodosData =
|
||||
mensaje.datos['jugadoresTodos'] as List<dynamic>?;
|
||||
setState(() {
|
||||
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
||||
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
||||
_jugadoresControlados
|
||||
..clear()
|
||||
..addAll(
|
||||
(jugadoresData ?? []).map(
|
||||
(json) => JugadorInicioPartida.fromJson(
|
||||
json as Map<String, dynamic>,
|
||||
),
|
||||
),
|
||||
);
|
||||
_jugadores
|
||||
..clear()
|
||||
..addAll(
|
||||
(jugadoresTodosData ?? []).map(
|
||||
(json) => Jugador.fromJson(json as Map<String, dynamic>),
|
||||
),
|
||||
);
|
||||
if (_jugadoresControlados.isNotEmpty) {
|
||||
final primero = _jugadoresControlados.first;
|
||||
_palabraRecibida = primero.palabra;
|
||||
_esImpostor = primero.esImpostor;
|
||||
} else {
|
||||
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
||||
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
||||
}
|
||||
_pistaCategoria = mensaje.datos['categoria'] as String?;
|
||||
});
|
||||
// Navegar a pantalla de palabra del cliente
|
||||
if (mounted && _palabraRecibida != null) {
|
||||
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
|
||||
_navegarAPalabra();
|
||||
}
|
||||
} else if (mensaje.tipo == TipoMensaje.fase) {
|
||||
@@ -71,6 +99,28 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
}
|
||||
|
||||
void _navegarAPalabra() {
|
||||
if (_jugadoresControlados.isNotEmpty) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaPalabrasCliente(
|
||||
jugadores: List.unmodifiable(_jugadoresControlados),
|
||||
pistaCategoria: _pistaCategoria,
|
||||
onTodosVistos: () {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
if (nearby.hostEndpointId != null) {
|
||||
nearby.enviarMensaje(
|
||||
nearby.hostEndpointId!,
|
||||
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaPalabraCliente(
|
||||
@@ -121,16 +171,23 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PantallaVotacionCliente(
|
||||
jugadores: _jugadores,
|
||||
onVoto: (votoporId) {
|
||||
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
|
||||
onVotos: (votos) {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
if (nearby.hostEndpointId != null) {
|
||||
nearby.enviarMensaje(
|
||||
nearby.hostEndpointId!,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.voto,
|
||||
datos: {'votoporId': votoporId},
|
||||
),
|
||||
);
|
||||
for (final entry in votos.entries) {
|
||||
nearby.enviarMensaje(
|
||||
nearby.hostEndpointId!,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.voto,
|
||||
datos: {
|
||||
'votanteId': entry.key,
|
||||
'votadoId': entry.value,
|
||||
'votoporId': entry.value,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
@@ -605,19 +662,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
...usuarios.map(_buildUsuarioSalaTile),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -671,34 +716,57 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
);
|
||||
|
||||
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);
|
||||
await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Envía el usuario seleccionado/creado al host
|
||||
/// 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')));
|
||||
}
|
||||
nearby.seleccionarUsuarioSala(usuario.id);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado')));
|
||||
}
|
||||
|
||||
Widget _buildUsuarioSalaTile(Usuario usuario) {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
final miClientId = nearby.miClientId;
|
||||
final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId;
|
||||
final seleccionadoPorOtro =
|
||||
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
|
||||
|
||||
return ListTile(
|
||||
leading: Text(
|
||||
usuario.avatar ?? '??',
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
title: Text(usuario.nombre),
|
||||
subtitle: Text(
|
||||
seleccionadoPorMi
|
||||
? 'Seleccionado por este m?vil'
|
||||
: seleccionadoPorOtro
|
||||
? 'No disponible'
|
||||
: 'Disponible',
|
||||
),
|
||||
trailing: seleccionadoPorMi
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => nearby.liberarUsuarioSala(usuario.id),
|
||||
)
|
||||
: null,
|
||||
enabled: !seleccionadoPorOtro,
|
||||
onTap: seleccionadoPorOtro
|
||||
? null
|
||||
: () {
|
||||
if (seleccionadoPorMi) {
|
||||
nearby.liberarUsuarioSala(usuario.id);
|
||||
} else {
|
||||
_enviarUsuarioAlHost(usuario);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
// ==================== HELPERS ====================
|
||||
|
||||
Widget _buildError(String msg) {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||
import 'package:farolero/modelos/jugador.dart';
|
||||
import 'package:farolero/tema/tema_app.dart';
|
||||
|
||||
/// Pantalla de votación para el cliente (multidispositivo).
|
||||
/// El cliente recibe fase=votacion y ve esta pantalla para elegir a quién votar.
|
||||
/// Pantalla de votación para cliente multidispositivo.
|
||||
/// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto
|
||||
/// por cada jugador controlado activo.
|
||||
class PantallaVotacionCliente extends StatefulWidget {
|
||||
final List<Jugador> jugadores;
|
||||
final Function(String votoporId) onVoto;
|
||||
final List<JugadorInicioPartida> jugadoresControlados;
|
||||
final Function(Map<String, String> votos) onVotos;
|
||||
|
||||
const PantallaVotacionCliente({
|
||||
super.key,
|
||||
required this.jugadores,
|
||||
required this.onVoto,
|
||||
this.jugadoresControlados = const [],
|
||||
required this.onVotos,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -20,7 +24,14 @@ class PantallaVotacionCliente extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
|
||||
String? _votoSeleccionado;
|
||||
final Map<String, String> _votosPorVotante = {};
|
||||
|
||||
List<JugadorInicioPartida> get _votantes => widget.jugadoresControlados;
|
||||
bool get _modoMultiVotante => _votantes.length > 1;
|
||||
bool get _votacionCompleta {
|
||||
if (_votantes.isEmpty) return _votosPorVotante.containsKey('_legacy');
|
||||
return _votantes.every((votante) => _votosPorVotante[votante.jugadorId] != null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -45,56 +56,31 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.selectOnePlayer,
|
||||
_modoMultiVotante
|
||||
? 'Emití un voto por cada jugador que manejás.'
|
||||
: l10n.selectOnePlayer,
|
||||
style: TextStyle(color: TemaApp.colorTextoSecundario),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: widget.jugadores.length,
|
||||
itemBuilder: (context, index) {
|
||||
final jugador = widget.jugadores[index];
|
||||
final selected = _votoSeleccionado == jugador.id;
|
||||
return Card(
|
||||
color: selected
|
||||
? TemaApp.colorAcento.withValues(alpha: 0.3)
|
||||
: TemaApp.colorTarjeta,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: selected
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorAcento.withValues(alpha: 0.3),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? Colors.white
|
||||
: TemaApp.colorTexto,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(jugador.nombre),
|
||||
trailing: selected
|
||||
? const Icon(Icons.check_circle,
|
||||
color: TemaApp.colorAcento)
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() => _votoSeleccionado = jugador.id);
|
||||
child: _votantes.isEmpty
|
||||
? _buildSelectorLegacy()
|
||||
: ListView.builder(
|
||||
itemCount: _votantes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final votante = _votantes[index];
|
||||
return _buildSelectorParaVotante(context, votante);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _votoSeleccionado == null
|
||||
? null
|
||||
: () => widget.onVoto(_votoSeleccionado!),
|
||||
onPressed: _votacionCompleta
|
||||
? () => widget.onVotos(Map.unmodifiable(_votosPorVotante))
|
||||
: null,
|
||||
icon: const Icon(Icons.how_to_vote),
|
||||
label: Text(l10n.votar),
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -109,4 +95,87 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectorLegacy() {
|
||||
return ListView.builder(
|
||||
itemCount: widget.jugadores.length,
|
||||
itemBuilder: (context, index) {
|
||||
final jugador = widget.jugadores[index];
|
||||
final selected = _votosPorVotante['_legacy'] == jugador.id;
|
||||
return _buildJugadorVotable(
|
||||
jugador: jugador,
|
||||
index: index,
|
||||
selected: selected,
|
||||
onTap: () => setState(() => _votosPorVotante['_legacy'] = jugador.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectorParaVotante(
|
||||
BuildContext context,
|
||||
JugadorInicioPartida votante,
|
||||
) {
|
||||
return Card(
|
||||
color: TemaApp.colorSuperficie,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Voto de ${votante.nombre}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...widget.jugadores.asMap().entries.map((entry) {
|
||||
final jugador = entry.value;
|
||||
final selected = _votosPorVotante[votante.jugadorId] == jugador.id;
|
||||
return _buildJugadorVotable(
|
||||
jugador: jugador,
|
||||
index: entry.key,
|
||||
selected: selected,
|
||||
onTap: () => setState(
|
||||
() => _votosPorVotante[votante.jugadorId] = jugador.id,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJugadorVotable({
|
||||
required Jugador jugador,
|
||||
required int index,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Card(
|
||||
color: selected
|
||||
? TemaApp.colorAcento.withValues(alpha: 0.3)
|
||||
: TemaApp.colorTarjeta,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: selected
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorAcento.withValues(alpha: 0.3),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : TemaApp.colorTexto,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(jugador.nombre),
|
||||
trailing: selected
|
||||
? const Icon(Icons.check_circle, color: TemaApp.colorAcento)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nearby_connections/nearby_connections.dart';
|
||||
import '../modelos/inicio_partida_multijugador.dart';
|
||||
import '../modelos/sala_multijugador.dart';
|
||||
import '../modelos/usuario.dart';
|
||||
|
||||
/// Tipos de mensajes en el protocolo P2P
|
||||
/// Tipos de mensajes en el protocolo P2P.
|
||||
enum TipoMensaje {
|
||||
salaInfo,
|
||||
partidaInicio,
|
||||
@@ -15,12 +17,20 @@ enum TipoMensaje {
|
||||
listo,
|
||||
ping,
|
||||
jugadorDesconectado,
|
||||
clienteRegistrado,
|
||||
estadoSala,
|
||||
crearUsuario,
|
||||
seleccionarUsuario,
|
||||
liberarUsuario,
|
||||
eliminarUsuario,
|
||||
errorOperacion,
|
||||
usuarioNuevo,
|
||||
// Compatibilidad con versiones previas del protocolo.
|
||||
usuarioEliminado,
|
||||
usuariosActualizados,
|
||||
}
|
||||
|
||||
/// Mensaje del protocolo P2P entre dispositivos
|
||||
/// Mensaje del protocolo P2P entre dispositivos.
|
||||
class MensajeP2P {
|
||||
final TipoMensaje tipo;
|
||||
final Map<String, dynamic> datos;
|
||||
@@ -40,7 +50,8 @@ class MensajeP2P {
|
||||
Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson()));
|
||||
}
|
||||
|
||||
/// Info de un jugador conectado
|
||||
/// Info de un dispositivo conectado. El nombre identifica al cliente/dispositivo,
|
||||
/// no necesariamente a un jugador de la partida.
|
||||
class JugadorConectado {
|
||||
final String endpointId;
|
||||
final String nombre;
|
||||
@@ -53,13 +64,13 @@ class JugadorConectado {
|
||||
});
|
||||
}
|
||||
|
||||
/// Callback para mensajes recibidos
|
||||
typedef OnMensajeCallback =
|
||||
void Function(String endpointId, MensajeP2P mensaje);
|
||||
|
||||
/// Servicio para conexiones P2P usando Google Nearby Connections API.
|
||||
class ServicioNearby extends ChangeNotifier {
|
||||
static const _serviceId = 'es.freetimelab.farolero';
|
||||
static const _hostClientId = 'host';
|
||||
|
||||
bool _esHost = false;
|
||||
bool _conectado = false;
|
||||
@@ -67,23 +78,21 @@ class ServicioNearby extends ChangeNotifier {
|
||||
bool _anunciando = false;
|
||||
String? _miEndpointId;
|
||||
String? _hostEndpointId;
|
||||
String? _roomId;
|
||||
String? _miClientId;
|
||||
String? _nombreSala;
|
||||
String? _miNombre;
|
||||
|
||||
final Map<String, JugadorConectado> _jugadores = {};
|
||||
final List<OnMensajeCallback> _listeners = [];
|
||||
final Map<String, String> _hostsEncontrados = {};
|
||||
final Map<String, Usuario> _usuariosPool = {};
|
||||
|
||||
// Hosts descubiertos (para discovery automático)
|
||||
final Map<String, String> _hostsEncontrados = {}; // endpointId -> nombre
|
||||
|
||||
// Estado para clientes
|
||||
String? _palabraRecibida;
|
||||
bool? _soyImpostor;
|
||||
String? _faseActual;
|
||||
Map<String, dynamic>? _datosPartida;
|
||||
|
||||
// Pool de usuarios para modo multi-dispositivo
|
||||
final Map<String, Usuario> _usuariosPool = {};
|
||||
EstadoSalaMultijugador? _estadoSala;
|
||||
|
||||
bool get esHost => _esHost;
|
||||
bool get conectado => _conectado;
|
||||
@@ -91,27 +100,35 @@ class ServicioNearby extends ChangeNotifier {
|
||||
bool get anunciando => _anunciando;
|
||||
String? get miEndpointId => _miEndpointId;
|
||||
String? get hostEndpointId => _hostEndpointId;
|
||||
String? get roomId => _roomId;
|
||||
String? get miClientId => _miClientId;
|
||||
String? get nombreSala => _nombreSala;
|
||||
String? get miNombre => _miNombre;
|
||||
String? get palabraRecibida => _palabraRecibida;
|
||||
bool? get soyImpostor => _soyImpostor;
|
||||
String? get faseActual => _faseActual;
|
||||
Map<String, dynamic>? get datosPartida => _datosPartida;
|
||||
EstadoSalaMultijugador? get estadoSala => _estadoSala;
|
||||
|
||||
List<JugadorConectado> get jugadores => _jugadores.values.toList();
|
||||
int get numJugadoresConectados => _jugadores.length;
|
||||
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();
|
||||
List<Usuario> get usuarios =>
|
||||
(_estadoSala?.usuarios.values ?? _usuariosPool.values).toList();
|
||||
|
||||
List<Usuario> get misUsuariosSeleccionados {
|
||||
final clientId = _miClientId;
|
||||
final sala = _estadoSala;
|
||||
if (clientId == null || sala == null) return [];
|
||||
return sala.usuariosPorCliente(clientId);
|
||||
}
|
||||
|
||||
/// Registra un listener de mensajes
|
||||
void onMensaje(OnMensajeCallback callback) {
|
||||
_listeners.add(callback);
|
||||
}
|
||||
|
||||
/// Elimina un listener
|
||||
void removeMensajeListener(OnMensajeCallback callback) {
|
||||
_listeners.remove(callback);
|
||||
}
|
||||
@@ -122,26 +139,24 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== USER POOL ====================
|
||||
// ==================== USER POOL / SALA ====================
|
||||
|
||||
/// Agrega un usuario al pool de usuarios
|
||||
void agregarUsuario(Usuario usuario) {
|
||||
_usuariosPool[usuario.id] = usuario;
|
||||
_estadoSala?.usuarios[usuario.id] = usuario;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Elimina un usuario del pool
|
||||
void eliminarUsuario(String usuarioId) {
|
||||
_usuariosPool.remove(usuarioId);
|
||||
_estadoSala?.usuarios.remove(usuarioId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Obtiene un usuario por su ID
|
||||
Usuario? getUsuario(String usuarioId) {
|
||||
return _usuariosPool[usuarioId];
|
||||
return _estadoSala?.usuarios[usuarioId] ?? _usuariosPool[usuarioId];
|
||||
}
|
||||
|
||||
/// Sincroniza el pool de usuarios con una lista
|
||||
void sincronizarUsuarios(List<Usuario> usuarios) {
|
||||
_usuariosPool.clear();
|
||||
for (final usuario in usuarios) {
|
||||
@@ -150,12 +165,38 @@ class ServicioNearby extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Obtiene el jugador local del host (él mismo como participante)
|
||||
/// Retorna un JugadorConectado con endpointId null porque es local
|
||||
void _sincronizarSala(EstadoSalaMultijugador sala) {
|
||||
_estadoSala = sala;
|
||||
_roomId = sala.roomId;
|
||||
_usuariosPool
|
||||
..clear()
|
||||
..addEntries(sala.usuarios.entries);
|
||||
}
|
||||
|
||||
void _sincronizarPoolDesdeSala() {
|
||||
final sala = _estadoSala;
|
||||
if (sala == null) return;
|
||||
_usuariosPool
|
||||
..clear()
|
||||
..addEntries(sala.usuarios.entries);
|
||||
}
|
||||
|
||||
Future<void> _broadcastEstadoSala() async {
|
||||
final sala = _estadoSala;
|
||||
if (sala == null) return;
|
||||
_sincronizarPoolDesdeSala();
|
||||
if (_esHost) {
|
||||
await enviarATodos(
|
||||
MensajeP2P(tipo: TipoMensaje.estadoSala, datos: sala.toJson()),
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
JugadorConectado? getJugadorLocal() {
|
||||
if (_miNombre == null) return null;
|
||||
return JugadorConectado(
|
||||
endpointId: _miEndpointId ?? '', // vacío indica que es el host local
|
||||
endpointId: _miEndpointId ?? '',
|
||||
nombre: _miNombre!,
|
||||
listo: true,
|
||||
);
|
||||
@@ -163,10 +204,29 @@ class ServicioNearby extends ChangeNotifier {
|
||||
|
||||
// ==================== HOST ====================
|
||||
|
||||
/// Inicia como host (anunciando el endpoint)
|
||||
Future<bool> iniciarHost(String nombreSala, String miNombre) async {
|
||||
_nombreSala = nombreSala;
|
||||
_miNombre = miNombre;
|
||||
_roomId = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
_miClientId = _hostClientId;
|
||||
_estadoSala = EstadoSalaMultijugador.crear(
|
||||
roomId: _roomId!,
|
||||
nombreSala: nombreSala,
|
||||
hostClientId: _hostClientId,
|
||||
hostNombre: miNombre,
|
||||
);
|
||||
|
||||
// Compatibilidad con el flujo actual: el nombre con el que se crea la sala
|
||||
// arranca como usuario seleccionado por el host. Luego puede crear/seleccionar
|
||||
// más usuarios en el lobby.
|
||||
final usuarioHost = Usuario(
|
||||
id: 'u-${_roomId!}-host',
|
||||
nombre: miNombre,
|
||||
creadoPorClienteId: _hostClientId,
|
||||
clienteIdSeleccionado: _hostClientId,
|
||||
);
|
||||
_estadoSala!.crearUsuario(usuarioHost);
|
||||
_sincronizarPoolDesdeSala();
|
||||
|
||||
try {
|
||||
final resultado = await Nearby().startAdvertising(
|
||||
@@ -194,7 +254,6 @@ class ServicioNearby extends ChangeNotifier {
|
||||
|
||||
// ==================== CLIENTE ====================
|
||||
|
||||
/// Busca hosts disponibles
|
||||
Future<bool> buscarHosts(String miNombre) async {
|
||||
_miNombre = miNombre;
|
||||
|
||||
@@ -219,7 +278,6 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Conecta a un host específico
|
||||
Future<bool> conectarAHost(String endpointId, String miNombre) async {
|
||||
try {
|
||||
await Nearby().requestConnection(
|
||||
@@ -239,8 +297,7 @@ class ServicioNearby extends ChangeNotifier {
|
||||
// ==================== CALLBACKS NEARBY ====================
|
||||
|
||||
void _onConexionIniciada(String endpointId, ConnectionInfo info) {
|
||||
debugPrint('Conexión iniciada con $endpointId: ${info.endpointName}');
|
||||
// Auto-aceptar conexiones
|
||||
debugPrint('Conexion iniciada con $endpointId: ${info.endpointName}');
|
||||
Nearby().acceptConnection(
|
||||
endpointId,
|
||||
onPayLoadRecieved: _onPayloadRecibido,
|
||||
@@ -249,16 +306,13 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _onResultadoConexion(String endpointId, Status status) {
|
||||
debugPrint('Resultado conexión $endpointId: $status');
|
||||
debugPrint('Resultado conexion $endpointId: $status');
|
||||
if (status == Status.CONNECTED) {
|
||||
if (_esHost) {
|
||||
// Host: esperar mensaje 'unirse' del cliente
|
||||
debugPrint('Cliente conectado: $endpointId');
|
||||
} else {
|
||||
// Cliente: conectado al host
|
||||
_hostEndpointId = endpointId;
|
||||
_conectado = true;
|
||||
// Enviar mensaje de unirse
|
||||
enviarMensaje(
|
||||
endpointId,
|
||||
MensajeP2P(
|
||||
@@ -269,16 +323,20 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
notifyListeners();
|
||||
} else {
|
||||
debugPrint('Conexión fallida con $endpointId');
|
||||
debugPrint('Conexion fallida con $endpointId');
|
||||
}
|
||||
}
|
||||
|
||||
void _onDesconexion(String endpointId) {
|
||||
debugPrint('Desconexión: $endpointId');
|
||||
debugPrint('Desconexion: $endpointId');
|
||||
if (_esHost) {
|
||||
final jugador = _jugadores.remove(endpointId);
|
||||
final cliente = _estadoSala?.clientePorEndpoint(endpointId);
|
||||
if (cliente != null) {
|
||||
_estadoSala?.desconectarCliente(cliente.clientId);
|
||||
_broadcastEstadoSala();
|
||||
}
|
||||
if (jugador != null) {
|
||||
// Notificar a todos que se desconectó
|
||||
enviarATodos(
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.jugadorDesconectado,
|
||||
@@ -287,7 +345,6 @@ class ServicioNearby extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Cliente perdió conexión con host
|
||||
_conectado = false;
|
||||
_hostEndpointId = null;
|
||||
}
|
||||
@@ -312,7 +369,6 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Para el discovery sin desconectar
|
||||
Future<void> pararBusqueda() async {
|
||||
try {
|
||||
await Nearby().stopDiscovery();
|
||||
@@ -334,9 +390,7 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {
|
||||
// No necesitamos trackear progreso para bytes pequeños
|
||||
}
|
||||
void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {}
|
||||
|
||||
// ==================== PROCESAMIENTO DE MENSAJES ====================
|
||||
|
||||
@@ -355,33 +409,11 @@ class ServicioNearby extends ChangeNotifier {
|
||||
void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) {
|
||||
switch (mensaje.tipo) {
|
||||
case TipoMensaje.unirse:
|
||||
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador';
|
||||
_jugadores[endpointId] = JugadorConectado(
|
||||
endpointId: endpointId,
|
||||
nombre: nombre,
|
||||
);
|
||||
// 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();
|
||||
_registrarClienteRemoto(endpointId, mensaje);
|
||||
break;
|
||||
|
||||
case TipoMensaje.voto:
|
||||
// Propagar al flujo de juego
|
||||
_notificarMensaje(endpointId, mensaje);
|
||||
break;
|
||||
|
||||
case TipoMensaje.listo:
|
||||
final jugador = _jugadores[endpointId];
|
||||
if (jugador != null) {
|
||||
@@ -389,33 +421,69 @@ class ServicioNearby extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
break;
|
||||
|
||||
case TipoMensaje.crearUsuario:
|
||||
_handleCrearUsuario(endpointId, mensaje);
|
||||
break;
|
||||
case TipoMensaje.seleccionarUsuario:
|
||||
_handleSeleccionarUsuario(endpointId, mensaje);
|
||||
break;
|
||||
case TipoMensaje.liberarUsuario:
|
||||
_handleLiberarUsuario(endpointId, mensaje);
|
||||
break;
|
||||
case TipoMensaje.eliminarUsuario:
|
||||
case TipoMensaje.usuarioEliminado:
|
||||
_handleEliminarUsuario(endpointId, mensaje);
|
||||
break;
|
||||
case TipoMensaje.usuarioNuevo:
|
||||
_handleUsuarioNuevo(mensaje);
|
||||
break;
|
||||
|
||||
case TipoMensaje.usuariosActualizados:
|
||||
_handleUsuariosActualizados(mensaje);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _registrarClienteRemoto(String endpointId, MensajeP2P mensaje) {
|
||||
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador';
|
||||
final clientId = endpointId;
|
||||
_jugadores[endpointId] = JugadorConectado(
|
||||
endpointId: endpointId,
|
||||
nombre: nombre,
|
||||
);
|
||||
_estadoSala?.registrarCliente(
|
||||
ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre),
|
||||
);
|
||||
|
||||
enviarMensaje(
|
||||
endpointId,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.clienteRegistrado,
|
||||
datos: {
|
||||
'clientId': clientId,
|
||||
'sala': _nombreSala,
|
||||
'roomId': _roomId,
|
||||
'jugadores': _jugadores.values
|
||||
.map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId})
|
||||
.toList(),
|
||||
'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(),
|
||||
if (_estadoSala != null) 'estadoSala': _estadoSala!.toJson(),
|
||||
},
|
||||
),
|
||||
);
|
||||
_broadcastEstadoSala();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
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
|
||||
_estadoSala?.usuarios[nuevoUsuario.id] = nuevoUsuario;
|
||||
if (_esHost) {
|
||||
enviarATodos(
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.usuarioNuevo,
|
||||
datos: {'usuario': usuarioJson},
|
||||
),
|
||||
);
|
||||
_broadcastEstadoSala();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -433,11 +501,87 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
String _clientIdParaEndpoint(String endpointId, MensajeP2P mensaje) {
|
||||
return mensaje.datos['clientId'] as String? ??
|
||||
_estadoSala?.clientePorEndpoint(endpointId)?.clientId ??
|
||||
endpointId;
|
||||
}
|
||||
|
||||
Future<void> _enviarErrorOperacion(
|
||||
String endpointId,
|
||||
ResultadoOperacionSala resultado,
|
||||
) async {
|
||||
if (resultado.exitoso) return;
|
||||
await enviarMensaje(
|
||||
endpointId,
|
||||
MensajeP2P(tipo: TipoMensaje.errorOperacion, datos: resultado.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleCrearUsuario(String endpointId, MensajeP2P mensaje) {
|
||||
final sala = _estadoSala;
|
||||
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
|
||||
if (sala == null || usuarioJson == null) return;
|
||||
final clientId = _clientIdParaEndpoint(endpointId, mensaje);
|
||||
final usuario = Usuario.fromJson(usuarioJson).copiar(
|
||||
creadoPorClienteId: clientId,
|
||||
liberarSeleccion: true,
|
||||
);
|
||||
final resultadoCrear = sala.crearUsuario(usuario);
|
||||
if (!resultadoCrear.exitoso) {
|
||||
_enviarErrorOperacion(endpointId, resultadoCrear);
|
||||
return;
|
||||
}
|
||||
if (mensaje.datos['seleccionar'] == true) {
|
||||
final resultadoSeleccion = sala.seleccionarUsuario(
|
||||
usuarioId: usuario.id,
|
||||
clienteId: clientId,
|
||||
);
|
||||
_enviarErrorOperacion(endpointId, resultadoSeleccion);
|
||||
}
|
||||
_broadcastEstadoSala();
|
||||
}
|
||||
|
||||
void _handleSeleccionarUsuario(String endpointId, MensajeP2P mensaje) {
|
||||
final sala = _estadoSala;
|
||||
final usuarioId = mensaje.datos['usuarioId'] as String?;
|
||||
if (sala == null || usuarioId == null) return;
|
||||
final resultado = sala.seleccionarUsuario(
|
||||
usuarioId: usuarioId,
|
||||
clienteId: _clientIdParaEndpoint(endpointId, mensaje),
|
||||
);
|
||||
_enviarErrorOperacion(endpointId, resultado);
|
||||
_broadcastEstadoSala();
|
||||
}
|
||||
|
||||
void _handleLiberarUsuario(String endpointId, MensajeP2P mensaje) {
|
||||
final sala = _estadoSala;
|
||||
final usuarioId = mensaje.datos['usuarioId'] as String?;
|
||||
if (sala == null || usuarioId == null) return;
|
||||
final resultado = sala.liberarUsuario(
|
||||
usuarioId: usuarioId,
|
||||
solicitanteClientId: _clientIdParaEndpoint(endpointId, mensaje),
|
||||
);
|
||||
_enviarErrorOperacion(endpointId, resultado);
|
||||
_broadcastEstadoSala();
|
||||
}
|
||||
|
||||
void _handleEliminarUsuario(String endpointId, MensajeP2P mensaje) {
|
||||
final sala = _estadoSala;
|
||||
final usuarioId = mensaje.datos['usuarioId'] as String?;
|
||||
if (sala == null || usuarioId == null) return;
|
||||
final resultado = sala.eliminarUsuario(
|
||||
usuarioId: usuarioId,
|
||||
solicitanteClientId: _clientIdParaEndpoint(endpointId, mensaje),
|
||||
);
|
||||
_enviarErrorOperacion(endpointId, resultado);
|
||||
_broadcastEstadoSala();
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -448,42 +592,54 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.clienteRegistrado:
|
||||
_miClientId = mensaje.datos['clientId'] as String?;
|
||||
_datosPartida = mensaje.datos;
|
||||
final estadoSalaJson =
|
||||
mensaje.datos['estadoSala'] as Map<String, dynamic>?;
|
||||
if (estadoSalaJson != null) {
|
||||
_sincronizarSala(EstadoSalaMultijugador.fromJson(estadoSalaJson));
|
||||
}
|
||||
notifyListeners();
|
||||
break;
|
||||
case TipoMensaje.estadoSala:
|
||||
_sincronizarSala(EstadoSalaMultijugador.fromJson(mensaje.datos));
|
||||
notifyListeners();
|
||||
break;
|
||||
case TipoMensaje.partidaInicio:
|
||||
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
||||
_soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
||||
final jugadoresInicio = mensaje.datos['jugadores'] as List<dynamic>?;
|
||||
if (jugadoresInicio != null && jugadoresInicio.isNotEmpty) {
|
||||
final primerJugador = jugadoresInicio.first as Map<String, dynamic>;
|
||||
_palabraRecibida = primerJugador['palabra'] as String?;
|
||||
_soyImpostor = primerJugador['esImpostor'] as bool? ?? false;
|
||||
} else {
|
||||
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
||||
_soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
||||
}
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.fase:
|
||||
_faseActual = mensaje.datos['fase'] as String?;
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.votacionResultado:
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.partidaFin:
|
||||
case TipoMensaje.errorOperacion:
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.jugadorDesconectado:
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ENVÍO ====================
|
||||
// ==================== ENVIO ====================
|
||||
|
||||
/// Envía un mensaje a un dispositivo específico
|
||||
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
|
||||
try {
|
||||
await Nearby().sendBytesPayload(endpointId, mensaje.toBytes());
|
||||
@@ -492,20 +648,113 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envía un mensaje a todos los dispositivos conectados (solo host)
|
||||
Future<void> enviarATodos(MensajeP2P mensaje) async {
|
||||
for (final id in _jugadores.keys) {
|
||||
await enviarMensaje(id, mensaje);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> crearUsuarioSala(String nombre, {bool seleccionar = true}) async {
|
||||
final nombreLimpio = nombre.trim();
|
||||
if (nombreLimpio.isEmpty) return;
|
||||
final clientId = _miClientId;
|
||||
final usuario = Usuario(
|
||||
id: 'u-${DateTime.now().microsecondsSinceEpoch}',
|
||||
nombre: nombreLimpio,
|
||||
creadoPorClienteId: clientId,
|
||||
);
|
||||
if (_esHost && _estadoSala != null && clientId != null) {
|
||||
final resultado = _estadoSala!.crearUsuario(usuario);
|
||||
if (resultado.exitoso && seleccionar) {
|
||||
_estadoSala!.seleccionarUsuario(
|
||||
usuarioId: usuario.id,
|
||||
clienteId: clientId,
|
||||
);
|
||||
}
|
||||
await _broadcastEstadoSala();
|
||||
return;
|
||||
}
|
||||
final hostId = _hostEndpointId;
|
||||
if (hostId == null) return;
|
||||
await enviarMensaje(
|
||||
hostId,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.crearUsuario,
|
||||
datos: {
|
||||
'clientId': clientId,
|
||||
'seleccionar': seleccionar,
|
||||
'usuario': usuario.toJson(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> seleccionarUsuarioSala(String usuarioId) async {
|
||||
final clientId = _miClientId;
|
||||
if (_esHost && _estadoSala != null && clientId != null) {
|
||||
_estadoSala!.seleccionarUsuario(usuarioId: usuarioId, clienteId: clientId);
|
||||
await _broadcastEstadoSala();
|
||||
return;
|
||||
}
|
||||
final hostId = _hostEndpointId;
|
||||
if (hostId == null) return;
|
||||
await enviarMensaje(
|
||||
hostId,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.seleccionarUsuario,
|
||||
datos: {'clientId': clientId, 'usuarioId': usuarioId},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> liberarUsuarioSala(String usuarioId) async {
|
||||
final clientId = _miClientId;
|
||||
if (_esHost && _estadoSala != null && clientId != null) {
|
||||
_estadoSala!.liberarUsuario(
|
||||
usuarioId: usuarioId,
|
||||
solicitanteClientId: clientId,
|
||||
);
|
||||
await _broadcastEstadoSala();
|
||||
return;
|
||||
}
|
||||
final hostId = _hostEndpointId;
|
||||
if (hostId == null) return;
|
||||
await enviarMensaje(
|
||||
hostId,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.liberarUsuario,
|
||||
datos: {'clientId': clientId, 'usuarioId': usuarioId},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> eliminarUsuarioSala(String usuarioId) async {
|
||||
final clientId = _miClientId;
|
||||
if (_esHost && _estadoSala != null && clientId != null) {
|
||||
_estadoSala!.eliminarUsuario(
|
||||
usuarioId: usuarioId,
|
||||
solicitanteClientId: clientId,
|
||||
);
|
||||
await _broadcastEstadoSala();
|
||||
return;
|
||||
}
|
||||
final hostId = _hostEndpointId;
|
||||
if (hostId == null) return;
|
||||
await enviarMensaje(
|
||||
hostId,
|
||||
MensajeP2P(
|
||||
tipo: TipoMensaje.eliminarUsuario,
|
||||
datos: {'clientId': clientId, 'usuarioId': usuarioId},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== HOST: ACCIONES DE JUEGO ====================
|
||||
|
||||
/// Host envía inicio de partida con la palabra de cada jugador
|
||||
Future<void> enviarInicioPartida({
|
||||
required String palabraSecreta,
|
||||
required String categoria,
|
||||
required Map<String, bool> impostores, // endpointId -> esImpostor
|
||||
required Map<String, bool> impostores,
|
||||
}) async {
|
||||
for (final entry in _jugadores.entries) {
|
||||
final esImpostor = impostores[entry.key] ?? false;
|
||||
@@ -517,14 +766,39 @@ class ServicioNearby extends ChangeNotifier {
|
||||
'palabra': esImpostor ? null : palabraSecreta,
|
||||
'esImpostor': esImpostor,
|
||||
'categoria': categoria,
|
||||
'numJugadores': _jugadores.length + 1, // +1 por el host
|
||||
'numJugadores': _jugadores.length + 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Host envía cambio de fase
|
||||
Future<void> enviarInicioPartidaMulti({
|
||||
required List<AsignacionJugador> asignaciones,
|
||||
required String palabraSecreta,
|
||||
required String categoria,
|
||||
required Map<String, bool> impostoresPorJugadorId,
|
||||
required List<Map<String, dynamic>> jugadoresTodos,
|
||||
}) async {
|
||||
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
|
||||
asignaciones: asignaciones,
|
||||
palabraSecreta: palabraSecreta,
|
||||
categoria: categoria,
|
||||
impostoresPorJugadorId: impostoresPorJugadorId,
|
||||
);
|
||||
|
||||
for (final payload in payloads.values) {
|
||||
final endpointId = payload.endpointId;
|
||||
if (endpointId == null) continue;
|
||||
final datos = payload.toJson();
|
||||
datos['jugadoresTodos'] = jugadoresTodos;
|
||||
await enviarMensaje(
|
||||
endpointId,
|
||||
MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enviarCambioFase(
|
||||
String fase, [
|
||||
Map<String, dynamic>? extra,
|
||||
@@ -533,14 +807,12 @@ class ServicioNearby extends ChangeNotifier {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/// Host envía fin de partida
|
||||
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
|
||||
await enviarATodos(
|
||||
MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado),
|
||||
@@ -549,7 +821,6 @@ class ServicioNearby extends ChangeNotifier {
|
||||
|
||||
// ==================== LIMPIEZA ====================
|
||||
|
||||
/// Desconecta y limpia todo
|
||||
Future<void> desconectar() async {
|
||||
try {
|
||||
await Nearby().stopAllEndpoints();
|
||||
@@ -565,27 +836,30 @@ class ServicioNearby extends ChangeNotifier {
|
||||
_anunciando = false;
|
||||
_miEndpointId = null;
|
||||
_hostEndpointId = null;
|
||||
_roomId = null;
|
||||
_miClientId = null;
|
||||
_nombreSala = null;
|
||||
_miNombre = null;
|
||||
_palabraRecibida = null;
|
||||
_soyImpostor = null;
|
||||
_faseActual = null;
|
||||
_datosPartida = null;
|
||||
_estadoSala = null;
|
||||
_jugadores.clear();
|
||||
_hostsEncontrados.clear();
|
||||
_usuariosPool.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Genera los datos para el código QR de conexión
|
||||
String generarDatosQR(String nombreSala) {
|
||||
return json.encode({
|
||||
'app': 'farolero',
|
||||
'sala': nombreSala,
|
||||
'host': _miNombre,
|
||||
'roomId': _roomId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Parsea datos de QR escaneado
|
||||
static Map<String, dynamic>? parsearQR(String datos) {
|
||||
try {
|
||||
final mapa = json.decode(datos) as Map<String, dynamic>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: farolero
|
||||
description: "Farolero — Juego de deducción social. ¿Quién finge saber?"
|
||||
publish_to: 'none'
|
||||
version: 1.1.6+11
|
||||
version: 1.1.9+14
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.1
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:farolero/estado/estado_juego.dart';
|
||||
|
||||
|
||||
77
test/modelos/inicio_partida_multijugador_test.dart
Normal file
77
test/modelos/inicio_partida_multijugador_test.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||
|
||||
void main() {
|
||||
group('InicioPartidaMultijugador', () {
|
||||
test('agrupa varios jugadores controlados por el mismo cliente', () {
|
||||
final asignaciones = [
|
||||
const AsignacionJugador(
|
||||
jugadorId: 'ana',
|
||||
nombre: 'Ana',
|
||||
clientId: 'host',
|
||||
endpointId: null,
|
||||
),
|
||||
const AsignacionJugador(
|
||||
jugadorId: 'juan',
|
||||
nombre: 'Juan',
|
||||
clientId: 'host',
|
||||
endpointId: null,
|
||||
),
|
||||
const AsignacionJugador(
|
||||
jugadorId: 'sofia',
|
||||
nombre: 'Sofía',
|
||||
clientId: 'cliente-sofia',
|
||||
endpointId: 'endpoint-2',
|
||||
),
|
||||
];
|
||||
final impostores = {'juan': true};
|
||||
|
||||
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
|
||||
asignaciones: asignaciones,
|
||||
palabraSecreta: 'mate',
|
||||
categoria: 'objetos',
|
||||
impostoresPorJugadorId: impostores,
|
||||
);
|
||||
|
||||
expect(payloads['host']?.jugadores.map((j) => j.jugadorId), [
|
||||
'ana',
|
||||
'juan',
|
||||
]);
|
||||
expect(payloads['host']?.jugadores.first.palabra, 'mate');
|
||||
expect(payloads['host']?.jugadores.last.esImpostor, isTrue);
|
||||
expect(payloads['host']?.jugadores.last.palabra, isNull);
|
||||
expect(payloads['cliente-sofia']?.endpointId, 'endpoint-2');
|
||||
expect(payloads['cliente-sofia']?.jugadores.single.nombre, 'Sofía');
|
||||
});
|
||||
|
||||
test('restaura payload completo desde json', () {
|
||||
final payload = InicioPartidaCliente(
|
||||
clientId: 'cliente-sofia',
|
||||
endpointId: 'endpoint-2',
|
||||
categoria: 'todas',
|
||||
jugadores: const [
|
||||
JugadorInicioPartida(
|
||||
jugadorId: 'sofia',
|
||||
nombre: 'Sofía',
|
||||
esImpostor: false,
|
||||
palabra: 'luna',
|
||||
),
|
||||
JugadorInicioPartida(
|
||||
jugadorId: 'helena',
|
||||
nombre: 'Helena',
|
||||
esImpostor: true,
|
||||
palabra: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final restaurado = InicioPartidaCliente.fromJson(payload.toJson());
|
||||
|
||||
expect(restaurado.clientId, 'cliente-sofia');
|
||||
expect(restaurado.endpointId, 'endpoint-2');
|
||||
expect(restaurado.jugadores.length, 2);
|
||||
expect(restaurado.jugadores.first.palabra, 'luna');
|
||||
expect(restaurado.jugadores.last.esImpostor, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
156
test/modelos/sala_multijugador_test.dart
Normal file
156
test/modelos/sala_multijugador_test.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:farolero/modelos/sala_multijugador.dart';
|
||||
import 'package:farolero/modelos/usuario.dart';
|
||||
|
||||
void main() {
|
||||
group('EstadoSalaMultijugador', () {
|
||||
late EstadoSalaMultijugador sala;
|
||||
|
||||
setUp(() {
|
||||
sala = EstadoSalaMultijugador.crear(
|
||||
roomId: 'room-1',
|
||||
nombreSala: 'Ana - Farolero',
|
||||
hostClientId: 'host',
|
||||
hostNombre: 'Ana',
|
||||
);
|
||||
});
|
||||
|
||||
test('registra al host como cliente conectado de la sala', () {
|
||||
expect(sala.hostClientId, 'host');
|
||||
expect(sala.clientes['host']?.esHost, isTrue);
|
||||
expect(sala.clientes['host']?.conectado, isTrue);
|
||||
});
|
||||
|
||||
test('permite seleccionar varios usuarios para un mismo cliente', () {
|
||||
sala.crearUsuario(Usuario(id: 'ana', nombre: 'Ana'));
|
||||
sala.crearUsuario(Usuario(id: 'juan', nombre: 'Juan'));
|
||||
|
||||
final seleccionAna = sala.seleccionarUsuario(
|
||||
usuarioId: 'ana',
|
||||
clienteId: 'host',
|
||||
);
|
||||
final seleccionJuan = sala.seleccionarUsuario(
|
||||
usuarioId: 'juan',
|
||||
clienteId: 'host',
|
||||
);
|
||||
|
||||
expect(seleccionAna.exitoso, isTrue);
|
||||
expect(seleccionJuan.exitoso, isTrue);
|
||||
expect(sala.usuariosPorCliente('host').map((u) => u.nombre), [
|
||||
'Ana',
|
||||
'Juan',
|
||||
]);
|
||||
});
|
||||
|
||||
test('impide que dos clientes seleccionen el mismo usuario', () {
|
||||
sala.registrarCliente(
|
||||
const ClienteSala(
|
||||
clientId: 'cliente-jorge',
|
||||
endpointId: 'endpoint-1',
|
||||
nombre: 'Jorge',
|
||||
),
|
||||
);
|
||||
sala.crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía'));
|
||||
|
||||
final primeraSeleccion = sala.seleccionarUsuario(
|
||||
usuarioId: 'sofia',
|
||||
clienteId: 'host',
|
||||
);
|
||||
final segundaSeleccion = sala.seleccionarUsuario(
|
||||
usuarioId: 'sofia',
|
||||
clienteId: 'cliente-jorge',
|
||||
);
|
||||
|
||||
expect(primeraSeleccion.exitoso, isTrue);
|
||||
expect(segundaSeleccion.exitoso, isFalse);
|
||||
expect(segundaSeleccion.codigo, 'usuario_ya_seleccionado');
|
||||
expect(sala.usuarios['sofia']?.clienteIdSeleccionado, 'host');
|
||||
});
|
||||
|
||||
test('solo permite eliminar usuarios no seleccionados y por el host', () {
|
||||
sala.registrarCliente(
|
||||
const ClienteSala(
|
||||
clientId: 'cliente-jorge',
|
||||
endpointId: 'endpoint-1',
|
||||
nombre: 'Jorge',
|
||||
),
|
||||
);
|
||||
sala.crearUsuario(Usuario(id: 'ana', nombre: 'Ana'));
|
||||
sala.crearUsuario(Usuario(id: 'javier', nombre: 'Javier'));
|
||||
sala.seleccionarUsuario(usuarioId: 'ana', clienteId: 'host');
|
||||
|
||||
final clienteElimina = sala.eliminarUsuario(
|
||||
usuarioId: 'javier',
|
||||
solicitanteClientId: 'cliente-jorge',
|
||||
);
|
||||
final hostEliminaSeleccionado = sala.eliminarUsuario(
|
||||
usuarioId: 'ana',
|
||||
solicitanteClientId: 'host',
|
||||
);
|
||||
final hostEliminaLibre = sala.eliminarUsuario(
|
||||
usuarioId: 'javier',
|
||||
solicitanteClientId: 'host',
|
||||
);
|
||||
|
||||
expect(clienteElimina.exitoso, isFalse);
|
||||
expect(clienteElimina.codigo, 'solo_host');
|
||||
expect(hostEliminaSeleccionado.exitoso, isFalse);
|
||||
expect(hostEliminaSeleccionado.codigo, 'usuario_seleccionado');
|
||||
expect(hostEliminaLibre.exitoso, isTrue);
|
||||
expect(sala.usuarios.containsKey('javier'), isFalse);
|
||||
});
|
||||
|
||||
test('valida inicio con minimo tres usuarios y host seleccionado', () {
|
||||
sala
|
||||
..crearUsuario(Usuario(id: 'ana', nombre: 'Ana'))
|
||||
..crearUsuario(Usuario(id: 'juan', nombre: 'Juan'))
|
||||
..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía'));
|
||||
|
||||
expect(sala.validarInicio().exitoso, isFalse);
|
||||
expect(sala.validarInicio().codigo, 'faltan_jugadores');
|
||||
|
||||
sala
|
||||
..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host')
|
||||
..seleccionarUsuario(usuarioId: 'juan', clienteId: 'host')
|
||||
..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'host');
|
||||
|
||||
expect(sala.validarInicio().exitoso, isTrue);
|
||||
expect(sala.iniciarPartida().exitoso, isTrue);
|
||||
expect(sala.fase, FaseSalaMultijugador.enPartida);
|
||||
});
|
||||
|
||||
test('libera usuarios de un cliente desconectado durante lobby', () {
|
||||
sala.registrarCliente(
|
||||
const ClienteSala(
|
||||
clientId: 'cliente-sofia',
|
||||
endpointId: 'endpoint-2',
|
||||
nombre: 'Sofía',
|
||||
),
|
||||
);
|
||||
sala
|
||||
..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía'))
|
||||
..crearUsuario(Usuario(id: 'helena', nombre: 'Helena'))
|
||||
..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'cliente-sofia')
|
||||
..seleccionarUsuario(usuarioId: 'helena', clienteId: 'cliente-sofia');
|
||||
|
||||
sala.desconectarCliente('cliente-sofia');
|
||||
|
||||
expect(sala.clientes['cliente-sofia']?.conectado, isFalse);
|
||||
expect(sala.usuarios['sofia']?.estaDisponible, isTrue);
|
||||
expect(sala.usuarios['helena']?.estaDisponible, isTrue);
|
||||
});
|
||||
|
||||
test('serializa y restaura clientes y usuarios seleccionados', () {
|
||||
sala
|
||||
..crearUsuario(Usuario(id: 'ana', nombre: 'Ana'))
|
||||
..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host');
|
||||
|
||||
final restaurada = EstadoSalaMultijugador.fromJson(sala.toJson());
|
||||
|
||||
expect(restaurada.roomId, 'room-1');
|
||||
expect(restaurada.clientes['host']?.esHost, isTrue);
|
||||
expect(restaurada.usuarios['ana']?.clienteIdSeleccionado, 'host');
|
||||
expect(restaurada.usuariosSeleccionados.single.nombre, 'Ana');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user