NUEVA GESTIÓN DE USUARIOS Y PARTIDAS

This commit is contained in:
2026-05-09 16:23:55 +02:00
parent f64f36b78f
commit a5d24c2721
11 changed files with 606 additions and 81 deletions

View File

@@ -32,6 +32,7 @@ class ClienteSala {
final String nombre; final String nombre;
final bool esHost; final bool esHost;
final bool conectado; final bool conectado;
final int ultimaActividadMs;
const ClienteSala({ const ClienteSala({
required this.clientId, required this.clientId,
@@ -39,6 +40,7 @@ class ClienteSala {
required this.nombre, required this.nombre,
this.esHost = false, this.esHost = false,
this.conectado = true, this.conectado = true,
this.ultimaActividadMs = 0,
}); });
ClienteSala copiar({ ClienteSala copiar({
@@ -47,6 +49,7 @@ class ClienteSala {
String? nombre, String? nombre,
bool? esHost, bool? esHost,
bool? conectado, bool? conectado,
int? ultimaActividadMs,
}) { }) {
return ClienteSala( return ClienteSala(
clientId: clientId ?? this.clientId, clientId: clientId ?? this.clientId,
@@ -54,6 +57,7 @@ class ClienteSala {
nombre: nombre ?? this.nombre, nombre: nombre ?? this.nombre,
esHost: esHost ?? this.esHost, esHost: esHost ?? this.esHost,
conectado: conectado ?? this.conectado, conectado: conectado ?? this.conectado,
ultimaActividadMs: ultimaActividadMs ?? this.ultimaActividadMs,
); );
} }
@@ -63,6 +67,7 @@ class ClienteSala {
'nombre': nombre, 'nombre': nombre,
'esHost': esHost, 'esHost': esHost,
'conectado': conectado, 'conectado': conectado,
'ultimaActividadMs': ultimaActividadMs,
}; };
factory ClienteSala.fromJson(Map<String, dynamic> json) => ClienteSala( factory ClienteSala.fromJson(Map<String, dynamic> json) => ClienteSala(
@@ -71,6 +76,7 @@ class ClienteSala {
nombre: json['nombre'] as String, nombre: json['nombre'] as String,
esHost: json['esHost'] as bool? ?? false, esHost: json['esHost'] as bool? ?? false,
conectado: json['conectado'] as bool? ?? true, conectado: json['conectado'] as bool? ?? true,
ultimaActividadMs: (json['ultimaActividadMs'] as num?)?.toInt() ?? 0,
); );
} }
@@ -121,6 +127,19 @@ class EstadoSalaMultijugador {
int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length; int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length;
List<ClienteSala> get clientesDesconectados => clientes.values
.where((cliente) => !cliente.esHost && !cliente.conectado)
.toList();
List<Usuario> get usuariosDeClientesDesconectados {
final desconectados = clientesDesconectados
.map((cliente) => cliente.clientId)
.toSet();
return usuarios.values
.where((usuario) => desconectados.contains(usuario.clienteIdSeleccionado))
.toList();
}
List<Usuario> usuariosPorCliente(String clientId) { List<Usuario> usuariosPorCliente(String clientId) {
return usuarios.values return usuarios.values
.where((usuario) => usuario.clienteIdSeleccionado == clientId) .where((usuario) => usuario.clienteIdSeleccionado == clientId)
@@ -235,6 +254,33 @@ class EstadoSalaMultijugador {
} }
} }
void registrarActividadCliente(String clientId, {int? ahoraMs}) {
final cliente = clientes[clientId];
if (cliente == null) return;
clientes[clientId] = cliente.copiar(
conectado: true,
ultimaActividadMs:
ahoraMs ?? DateTime.now().millisecondsSinceEpoch,
);
}
int reasignarUsuariosDeCliente({
required String clientIdOrigen,
required String clientIdDestino,
}) {
if (!clientes.containsKey(clientIdDestino)) return 0;
var reasignados = 0;
for (final entry in usuarios.entries.toList()) {
if (entry.value.clienteIdSeleccionado == clientIdOrigen) {
usuarios[entry.key] = entry.value.copiar(
clienteIdSeleccionado: clientIdDestino,
);
reasignados++;
}
}
return reasignados;
}
ResultadoOperacionSala validarInicio() { ResultadoOperacionSala validarInicio() {
if (fase != FaseSalaMultijugador.lobby) { if (fase != FaseSalaMultijugador.lobby) {
return const ResultadoOperacionSala.error('sala_cerrada'); return const ResultadoOperacionSala.error('sala_cerrada');

View File

@@ -16,7 +16,14 @@ import 'pantalla_principal.dart';
import 'pantalla_ver_palabra.dart'; import 'pantalla_ver_palabra.dart';
class PantallaCrearPartida extends StatefulWidget { class PantallaCrearPartida extends StatefulWidget {
const PantallaCrearPartida({super.key}); final bool modoInicial;
final bool bloquearModo;
const PantallaCrearPartida({
super.key,
this.modoInicial = false,
this.bloquearModo = false,
});
@override @override
State<PantallaCrearPartida> createState() => _PantallaCrearPartidaState(); State<PantallaCrearPartida> createState() => _PantallaCrearPartidaState();
@@ -33,7 +40,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
final _opcionesTiempo = <int?>[null, 60, 120, 180, 300]; final _opcionesTiempo = <int?>[null, 60, 120, 180, 300];
int get _maxImpostores => (_jugadores.length / 3).floor().clamp(1, 4); @override
void initState() {
super.initState();
_modoMultimovil = widget.modoInicial;
}
int get _maxImpostores =>
_modoMultimovil ? 4 : (_jugadores.length / 3).floor().clamp(1, 4);
List<String> _etiquetasTiempo(AppLocalizations l10n) => [ List<String> _etiquetasTiempo(AppLocalizations l10n) => [
l10n.noLimit, l10n.noLimit,
@@ -295,42 +309,70 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Modo de juego if (!widget.bloquearModo) ...[
Card( // Modo de juego
child: Padding( Card(
padding: const EdgeInsets.all(16), child: Padding(
child: Column( padding: const EdgeInsets.all(16),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.gameMode,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
SegmentedButton<bool>(
segments: [
ButtonSegment(
value: false,
label: Text(l10n.singleDevice),
icon: const Icon(Icons.phone_android),
),
ButtonSegment(
value: true,
label: Text(l10n.multiDevice),
icon: const Icon(Icons.devices),
),
],
selected: {_modoMultimovil},
onSelectionChanged: (valor) {
setState(() {
_modoMultimovil = valor.first;
if (_numImpostores > _maxImpostores) {
_numImpostores = _maxImpostores;
}
});
},
),
],
),
),
),
const SizedBox(height: 12),
] else ...[
PanelFarolero(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [ children: [
Text( Icon(
l10n.gameMode, _modoMultimovil ? Icons.devices : Icons.phone_android,
style: Theme.of(context).textTheme.titleLarge, color: TemaApp.colorNaranja,
), ),
const SizedBox(height: 12), const SizedBox(width: 12),
SegmentedButton<bool>( Expanded(
segments: [ child: Text(
ButtonSegment( _modoMultimovil
value: false, ? 'Partida multidispositivo'
label: Text(l10n.singleDevice), : 'Partida en este dispositivo',
icon: const Icon(Icons.phone_android), style: Theme.of(context).textTheme.titleMedium,
), ),
ButtonSegment(
value: true,
label: Text(l10n.multiDevice),
icon: const Icon(Icons.devices),
),
],
selected: {_modoMultimovil},
onSelectionChanged: (valor) {
setState(() => _modoMultimovil = valor.first);
},
), ),
], ],
), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), ],
// Categoría // Categoría
Card( Card(
child: Padding( child: Padding(
@@ -367,13 +409,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Jugadores if (!_modoMultimovil) ...[
Card( // Jugadores
child: Padding( Card(
padding: const EdgeInsets.all(16), child: Padding(
child: Column( padding: const EdgeInsets.all(16),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -429,11 +472,12 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
dense: true, dense: true,
); );
}), }),
], ],
),
), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), ],
// Configuración de partida // Configuración de partida
Card( Card(

View File

@@ -4,13 +4,14 @@ import 'package:provider/provider.dart';
import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/snapshot_partida_online.dart'; import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart'; import 'pantalla_notas_online.dart';
import 'pantalla_principal.dart'; import 'pantalla_principal.dart';
import 'pantalla_revision_palabra.dart'; import 'pantalla_revision_palabra.dart';
class PantallaFinPartidaOnline extends StatelessWidget { class PantallaFinPartidaOnline extends StatefulWidget {
final SnapshotPartidaOnline snapshot; final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados; final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaCategoria; final String? pistaCategoria;
@@ -22,11 +23,33 @@ class PantallaFinPartidaOnline extends StatelessWidget {
this.pistaCategoria, this.pistaCategoria,
}); });
@override
State<PantallaFinPartidaOnline> createState() =>
_PantallaFinPartidaOnlineState();
}
class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
bool _guardada = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final snapshot = widget.snapshot;
final jugadoresControlados = widget.jugadoresControlados;
final pistaCategoria = widget.pistaCategoria;
final ganaronJugadores = snapshot.ganador == 'jugadores'; final ganaronJugadores = snapshot.ganador == 'jugadores';
if (!_guardada && snapshot.ganador != null) {
_guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
context
.read<ServicioHistorialPartidas>()
.guardarSnapshotOnline(snapshot);
}
});
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(l10n.gameOver), title: Text(l10n.gameOver),

View File

@@ -8,6 +8,7 @@ import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart'; import '../modelos/jugador.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/snapshot_partida_online.dart'; import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../tema/componentes_farolero.dart'; import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
@@ -29,6 +30,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
Timer? _timer; Timer? _timer;
int _segundosRestantes = 0; int _segundosRestantes = 0;
bool _hostListo = false; bool _hostListo = false;
bool _partidaOnlineGuardada = false;
String? _primerTurnoId; String? _primerTurnoId;
String? _primerTurnoNombre; String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {}; final Map<String, bool> _clientesListos = {};
@@ -200,6 +202,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
_buildAvisoClientesDesconectados(context, nearby),
_buildFaseIndicator(context, partida.fase, l10n), _buildFaseIndicator(context, partida.fase, l10n),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
@@ -226,6 +229,58 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
); );
} }
Widget _buildAvisoClientesDesconectados(
BuildContext context,
ServicioNearby nearby,
) {
final sala = nearby.estadoSala;
final usuariosAfectados = sala?.usuariosDeClientesDesconectados ?? const [];
if (usuariosAfectados.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorAcento.withValues(alpha: 0.16),
borderColor: TemaApp.colorAcento.withValues(alpha: 0.65),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.link_off, color: TemaApp.colorAcento),
const SizedBox(width: 8),
Expanded(
child: Text(
'Hay jugadores con el dispositivo desconectado.',
style: Theme.of(context).textTheme.titleSmall,
),
),
],
),
const SizedBox(height: 6),
Text(
usuariosAfectados.map((usuario) => usuario.nombre).join(', '),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: () => nearby.asumirUsuariosDesconectados(),
icon: const Icon(Icons.person_add_alt_1),
label: const Text('Asumir en este móvil'),
),
),
],
),
),
);
}
Widget _buildFaseIndicator( Widget _buildFaseIndicator(
BuildContext context, BuildContext context,
FaseJuego fase, FaseJuego fase,
@@ -1115,12 +1170,21 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
final estado = context.read<EstadoJuego>(); final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
estado.comprobarFinPartida(); estado.comprobarFinPartida();
await nearby.enviarFinPartida( final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true)
_snapshot(fase: 'finPartida', revelarFinal: true).toJson(), .toJson();
); await _guardarHistorialOnlineHost(context);
await nearby.enviarFinPartida(snapshotFinal);
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
Future<void> _guardarHistorialOnlineHost(BuildContext context) async {
if (_partidaOnlineGuardada) return;
final partida = context.read<EstadoJuego>().partida;
if (partida?.ganador == null) return;
_partidaOnlineGuardada = true;
await context.read<ServicioHistorialPartidas>().guardarPartida(partida!);
}
Future<void> _iniciarAdivinanzaOnline(BuildContext context) async { Future<void> _iniciarAdivinanzaOnline(BuildContext context) async {
final estado = context.read<EstadoJuego>(); final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
@@ -1170,9 +1234,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
final acierto = estado.intentarAdivinar(intento); final acierto = estado.intentarAdivinar(intento);
if (acierto) { if (acierto) {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
await nearby.enviarFinPartida( final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true)
_snapshot(fase: 'finPartida', revelarFinal: true).toJson(), .toJson();
); await _guardarHistorialOnlineHost(context);
await nearby.enviarFinPartida(snapshotFinal);
if (mounted) setState(() {}); if (mounted) setState(() {});
return; return;
} }

View File

@@ -5,7 +5,7 @@ import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart'; import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_ajustes.dart'; import 'pantalla_ajustes.dart';
import 'pantalla_crear_partida.dart'; import 'pantalla_seleccion_modo_juego.dart';
import 'pantalla_historial.dart'; import 'pantalla_historial.dart';
import 'pantalla_reglas.dart'; import 'pantalla_reglas.dart';
import 'pantalla_unirse.dart'; import 'pantalla_unirse.dart';
@@ -94,7 +94,7 @@ class PantallaPrincipal extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const PantallaCrearPartida(), builder: (_) => const PantallaSeleccionModoJuego(),
), ),
); );
}, },
@@ -142,30 +142,6 @@ class PantallaPrincipal extends StatelessWidget {
}, },
), ),
), ),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Logros',
icono: Icons.emoji_events,
onPressed: () {},
),
),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Ranking',
icono: Icons.bar_chart,
onPressed: () {},
),
),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Tienda',
icono: Icons.storefront,
onPressed: () {},
),
),
], ],
), ),
const SizedBox(height: 28), const SizedBox(height: 28),

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_crear_partida.dart';
class PantallaSeleccionModoJuego extends StatelessWidget {
const PantallaSeleccionModoJuego({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Elegir modo de juego')),
body: FondoFarolero(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.sports_esports,
size: 64,
color: TemaApp.colorNaranja,
),
const SizedBox(height: 16),
Text(
'¿Cómo querés jugar?',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Elegí primero el tipo de partida para configurar solo lo necesario.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
_ModoCard(
icono: Icons.phone_android,
titulo: 'Partida en este dispositivo',
descripcion:
'Todos los jugadores usan este móvil. Acá se agregan los nombres manualmente.',
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaCrearPartida(
modoInicial: false,
bloquearModo: true,
),
),
),
),
const SizedBox(height: 14),
_ModoCard(
icono: Icons.devices,
titulo: 'Partida multidispositivo',
descripcion:
'Este móvil crea el servidor. Los usuarios se gestionan después en el lobby.',
destacado: true,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaCrearPartida(
modoInicial: true,
bloquearModo: true,
),
),
),
),
],
),
),
),
),
),
),
);
}
}
class _ModoCard extends StatelessWidget {
final IconData icono;
final String titulo;
final String descripcion;
final bool destacado;
final VoidCallback onTap;
const _ModoCard({
required this.icono,
required this.titulo,
required this.descripcion,
required this.onTap,
this.destacado = false,
});
@override
Widget build(BuildContext context) {
final color = destacado ? TemaApp.colorNaranja : TemaApp.colorAcento;
return Card(
color: TemaApp.colorTarjeta,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.7)),
),
child: Icon(icono, color: color, size: 30),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 6),
Text(
descripcion,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}

View File

@@ -314,7 +314,12 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (!mounted) return; if (!mounted) return;
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
final ok = await nearby.buscarHosts(_nombreController.text.trim()); final perfil = context.read<ServicioPerfilUsuario>().perfil;
final ok = await nearby.buscarHosts(
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
);
if (ok) { if (ok) {
setState(() { setState(() {
@@ -337,11 +342,14 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
}); });
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
// Parar discovery antes de conectar // Parar discovery antes de conectar
await nearby.pararBusqueda(); await nearby.pararBusqueda();
final ok = await nearby.conectarAHost( final ok = await nearby.conectarAHost(
endpointId, endpointId,
_nombreController.text.trim(), _nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
); );
if (!ok && mounted) { if (!ok && mounted) {
@@ -381,7 +389,12 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
// Iniciar búsqueda para que Nearby encuentre al host // Iniciar búsqueda para que Nearby encuentre al host
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (!nearby.buscando) { if (!nearby.buscando) {
await nearby.buscarHosts(_nombreController.text.trim()); final perfil = context.read<ServicioPerfilUsuario>().perfil;
await nearby.buscarHosts(
_nombreController.text.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
);
} }
return; return;
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/snapshot_partida_online.dart';
class ResultadoPartidaGuardado { class ResultadoPartidaGuardado {
final String id; final String id;
@@ -42,6 +43,25 @@ class ResultadoPartidaGuardado {
); );
} }
factory ResultadoPartidaGuardado.desdeSnapshotOnline(
SnapshotPartidaOnline snapshot,
) {
final impostores = snapshot.impostores.isNotEmpty
? snapshot.impostores.length
: snapshot.jugadores.where((jugador) => jugador.esImpostor).length;
return ResultadoPartidaGuardado(
id: 'online-${snapshot.roomId ?? DateTime.now().microsecondsSinceEpoch}',
fecha: DateTime.now(),
modoMultimovil: true,
jugadores: snapshot.jugadores.length,
impostores: impostores,
rondas: snapshot.ronda,
ganador: snapshot.ganador ?? 'sin_resultado',
palabra: snapshot.palabraSecreta ?? '',
categoria: snapshot.categoria,
);
}
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'fecha': fecha.toIso8601String(), 'fecha': fecha.toIso8601String(),
@@ -99,6 +119,16 @@ class ServicioHistorialPartidas extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> guardarSnapshotOnline(SnapshotPartidaOnline snapshot) async {
if (snapshot.ganador == null || snapshot.palabraSecreta == null) return;
final guardado = ResultadoPartidaGuardado.desdeSnapshotOnline(snapshot);
if (_partidas.any((partida) => partida.id == guardado.id)) return;
_partidas.insert(0, guardado);
if (_partidas.length > 100) _partidas.removeRange(100, _partidas.length);
await _persistir();
notifyListeners();
}
Future<void> limpiar() async { Future<void> limpiar() async {
_partidas.clear(); _partidas.clear();
await _persistir(); await _persistir();

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:nearby_connections/nearby_connections.dart'; import 'package:nearby_connections/nearby_connections.dart';
import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/inicio_partida_multijugador.dart';
@@ -82,11 +83,14 @@ class ServicioNearby extends ChangeNotifier {
String? _miClientId; String? _miClientId;
String? _nombreSala; String? _nombreSala;
String? _miNombre; String? _miNombre;
String? _miNick;
String? _miAvatar;
final Map<String, JugadorConectado> _jugadores = {}; final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = []; final List<OnMensajeCallback> _listeners = [];
final Map<String, String> _hostsEncontrados = {}; final Map<String, String> _hostsEncontrados = {};
final Map<String, Usuario> _usuariosPool = {}; final Map<String, Usuario> _usuariosPool = {};
Timer? _heartbeatTimer;
String? _palabraRecibida; String? _palabraRecibida;
bool? _soyImpostor; bool? _soyImpostor;
@@ -273,8 +277,14 @@ class ServicioNearby extends ChangeNotifier {
// ==================== CLIENTE ==================== // ==================== CLIENTE ====================
Future<bool> buscarHosts(String miNombre) async { Future<bool> buscarHosts(
String miNombre, {
String? miNick,
String? miAvatar,
}) async {
_miNombre = miNombre; _miNombre = miNombre;
_miNick = miNick;
_miAvatar = miAvatar;
try { try {
final resultado = await Nearby().startDiscovery( final resultado = await Nearby().startDiscovery(
@@ -297,7 +307,15 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
Future<bool> conectarAHost(String endpointId, String miNombre) async { Future<bool> conectarAHost(
String endpointId,
String miNombre, {
String? miNick,
String? miAvatar,
}) async {
_miNombre = miNombre;
_miNick = miNick;
_miAvatar = miAvatar;
try { try {
await Nearby().requestConnection( await Nearby().requestConnection(
miNombre, miNombre,
@@ -332,11 +350,16 @@ class ServicioNearby extends ChangeNotifier {
} else { } else {
_hostEndpointId = endpointId; _hostEndpointId = endpointId;
_conectado = true; _conectado = true;
_iniciarHeartbeatCliente();
enviarMensaje( enviarMensaje(
endpointId, endpointId,
MensajeP2P( MensajeP2P(
tipo: TipoMensaje.unirse, tipo: TipoMensaje.unirse,
datos: {'nombre': _miNombre ?? 'Jugador'}, datos: {
'nombre': _miNombre ?? 'Jugador',
if (_miNick != null) 'nick': _miNick,
if (_miAvatar != null) 'avatar': _miAvatar,
},
), ),
); );
} }
@@ -366,10 +389,30 @@ class ServicioNearby extends ChangeNotifier {
} else { } else {
_conectado = false; _conectado = false;
_hostEndpointId = null; _hostEndpointId = null;
_heartbeatTimer?.cancel();
} }
notifyListeners(); notifyListeners();
} }
void _iniciarHeartbeatCliente() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (_) {
final hostId = _hostEndpointId;
if (!_esHost && _conectado && hostId != null) {
enviarMensaje(
hostId,
MensajeP2P(
tipo: TipoMensaje.ping,
datos: {
'heartbeat': true,
if (_miClientId != null) 'clientId': _miClientId,
},
),
);
}
});
}
void _onEndpointEncontrado( void _onEndpointEncontrado(
String endpointId, String endpointId,
String endpointName, String endpointName,
@@ -426,6 +469,10 @@ class ServicioNearby extends ChangeNotifier {
} }
void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) { void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) {
final cliente = _estadoSala?.clientePorEndpoint(endpointId);
if (cliente != null) {
_estadoSala?.registrarActividadCliente(cliente.clientId);
}
switch (mensaje.tipo) { switch (mensaje.tipo) {
case TipoMensaje.unirse: case TipoMensaje.unirse:
_registrarClienteRemoto(endpointId, mensaje); _registrarClienteRemoto(endpointId, mensaje);
@@ -466,6 +513,8 @@ class ServicioNearby extends ChangeNotifier {
void _registrarClienteRemoto(String endpointId, MensajeP2P mensaje) { void _registrarClienteRemoto(String endpointId, MensajeP2P mensaje) {
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador';
final nick = mensaje.datos['nick'] as String?;
final avatar = mensaje.datos['avatar'] as String?;
final clientId = endpointId; final clientId = endpointId;
_jugadores[endpointId] = JugadorConectado( _jugadores[endpointId] = JugadorConectado(
endpointId: endpointId, endpointId: endpointId,
@@ -474,6 +523,12 @@ class ServicioNearby extends ChangeNotifier {
_estadoSala?.registrarCliente( _estadoSala?.registrarCliente(
ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre), ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre),
); );
_crearUsuarioAutomaticoCliente(
clientId: clientId,
nombre: nombre,
nick: nick,
avatar: avatar,
);
enviarMensaje( enviarMensaje(
endpointId, endpointId,
@@ -495,6 +550,42 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void _crearUsuarioAutomaticoCliente({
required String clientId,
required String nombre,
String? nick,
String? avatar,
}) {
final sala = _estadoSala;
if (sala == null || sala.fase != FaseSalaMultijugador.lobby) return;
final yaTieneUsuario = sala.usuariosPorCliente(clientId).isNotEmpty;
if (yaTieneUsuario) return;
final base = nombre.trim().isEmpty ? 'Jugador' : nombre.trim();
var nombreFinal = base;
var intento = 2;
while (sala.usuarios.values.any(
(u) => u.nombre.trim().toLowerCase() == nombreFinal.toLowerCase(),
)) {
nombreFinal = '$base ($intento)';
intento++;
}
final usuario = Usuario(
id: 'u-${_roomId ?? DateTime.now().microsecondsSinceEpoch}-$clientId',
nombre: nombreFinal,
nick: nick,
avatar: avatar,
foto: avatar,
creadoPorClienteId: clientId,
);
final resultadoCrear = sala.crearUsuario(usuario);
if (!resultadoCrear.exitoso) return;
sala.seleccionarUsuario(usuarioId: usuario.id, clienteId: clientId);
_sincronizarPoolDesdeSala();
}
void _handleUsuarioNuevo(MensajeP2P mensaje) { void _handleUsuarioNuevo(MensajeP2P mensaje) {
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?; final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
if (usuarioJson != null) { if (usuarioJson != null) {
@@ -776,6 +867,22 @@ class ServicioNearby extends ChangeNotifier {
); );
} }
Future<void> asumirUsuariosDesconectados() async {
final sala = _estadoSala;
if (!_esHost || sala == null) return;
var huboCambios = false;
for (final cliente in sala.clientesDesconectados) {
final reasignados = sala.reasignarUsuariosDeCliente(
clientIdOrigen: cliente.clientId,
clientIdDestino: sala.hostClientId,
);
huboCambios = huboCambios || reasignados > 0;
}
if (huboCambios) {
await _broadcastEstadoSala();
}
}
// ==================== HOST: ACCIONES DE JUEGO ==================== // ==================== HOST: ACCIONES DE JUEGO ====================
Future<void> enviarInicioPartida({ Future<void> enviarInicioPartida({
@@ -851,6 +958,7 @@ class ServicioNearby extends ChangeNotifier {
// ==================== LIMPIEZA ==================== // ==================== LIMPIEZA ====================
Future<void> desconectar() async { Future<void> desconectar() async {
_heartbeatTimer?.cancel();
try { try {
await Nearby().stopAllEndpoints(); await Nearby().stopAllEndpoints();
if (_anunciando) await Nearby().stopAdvertising(); if (_anunciando) await Nearby().stopAdvertising();
@@ -869,6 +977,8 @@ class ServicioNearby extends ChangeNotifier {
_miClientId = null; _miClientId = null;
_nombreSala = null; _nombreSala = null;
_miNombre = null; _miNombre = null;
_miNick = null;
_miAvatar = null;
_palabraRecibida = null; _palabraRecibida = null;
_soyImpostor = null; _soyImpostor = null;
_faseActual = null; _faseActual = null;
@@ -877,6 +987,7 @@ class ServicioNearby extends ChangeNotifier {
_jugadores.clear(); _jugadores.clear();
_hostsEncontrados.clear(); _hostsEncontrados.clear();
_usuariosPool.clear(); _usuariosPool.clear();
_heartbeatTimer = null;
notifyListeners(); notifyListeners();
} }
@@ -901,6 +1012,7 @@ class ServicioNearby extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_heartbeatTimer?.cancel();
desconectar(); desconectar();
super.dispose(); super.dispose();
} }

View File

@@ -140,6 +140,44 @@ void main() {
expect(sala.usuarios['helena']?.estaDisponible, isTrue); expect(sala.usuarios['helena']?.estaDisponible, isTrue);
}); });
test('registra actividad y conserva usuarios seleccionados en partida al desconectar', () {
sala.registrarCliente(
const ClienteSala(
clientId: 'cliente-sofia',
endpointId: 'endpoint-2',
nombre: 'Sofía',
),
);
sala
..crearUsuario(Usuario(id: 'ana', nombre: 'Ana'))
..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía'))
..crearUsuario(Usuario(id: 'helena', nombre: 'Helena'))
..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host')
..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'cliente-sofia')
..seleccionarUsuario(usuarioId: 'helena', clienteId: 'cliente-sofia');
sala.registrarActividadCliente('cliente-sofia', ahoraMs: 1234);
expect(sala.clientes['cliente-sofia']?.conectado, isTrue);
expect(sala.clientes['cliente-sofia']?.ultimaActividadMs, 1234);
sala.iniciarPartida();
sala.desconectarCliente('cliente-sofia');
expect(sala.clientes['cliente-sofia']?.conectado, isFalse);
expect(sala.usuarios['sofia']?.clienteIdSeleccionado, 'cliente-sofia');
expect(sala.usuarios['helena']?.clienteIdSeleccionado, 'cliente-sofia');
expect(sala.usuariosDeClientesDesconectados.length, 2);
final reasignados = sala.reasignarUsuariosDeCliente(
clientIdOrigen: 'cliente-sofia',
clientIdDestino: 'host',
);
expect(reasignados, 2);
expect(sala.usuarios['sofia']?.clienteIdSeleccionado, 'host');
expect(sala.usuarios['helena']?.clienteIdSeleccionado, 'host');
});
test('serializa y restaura clientes y usuarios seleccionados', () { test('serializa y restaura clientes y usuarios seleccionados', () {
sala sala
..crearUsuario(Usuario(id: 'ana', nombre: 'Ana')) ..crearUsuario(Usuario(id: 'ana', nombre: 'Ana'))

View File

@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/modelos/snapshot_partida_online.dart';
import 'package:farolero/servicios/servicio_historial_partidas.dart';
void main() {
test('crea resumen de historial desde snapshot online final', () {
final snapshot = SnapshotPartidaOnline(
roomId: 'room-1',
fase: 'finPartida',
ronda: 2,
categoria: 'animales',
palabraSecreta: 'León',
ganador: 'jugadores',
jugadores: [
Jugador(id: 'j1', nombre: 'Ana'),
Jugador(id: 'j2', nombre: 'Bruno', esImpostor: true),
Jugador(id: 'j3', nombre: 'Clara'),
],
impostores: ['Bruno'],
);
final guardado = ResultadoPartidaGuardado.desdeSnapshotOnline(snapshot);
expect(guardado.id, 'online-room-1');
expect(guardado.modoMultimovil, isTrue);
expect(guardado.jugadores, 3);
expect(guardado.impostores, 1);
expect(guardado.rondas, 2);
expect(guardado.ganador, 'jugadores');
expect(guardado.palabra, 'León');
expect(guardado.categoria, 'animales');
});
}