NUEVA GESTIÓN DE USUARIOS Y PARTIDAS
This commit is contained in:
@@ -32,6 +32,7 @@ class ClienteSala {
|
||||
final String nombre;
|
||||
final bool esHost;
|
||||
final bool conectado;
|
||||
final int ultimaActividadMs;
|
||||
|
||||
const ClienteSala({
|
||||
required this.clientId,
|
||||
@@ -39,6 +40,7 @@ class ClienteSala {
|
||||
required this.nombre,
|
||||
this.esHost = false,
|
||||
this.conectado = true,
|
||||
this.ultimaActividadMs = 0,
|
||||
});
|
||||
|
||||
ClienteSala copiar({
|
||||
@@ -47,6 +49,7 @@ class ClienteSala {
|
||||
String? nombre,
|
||||
bool? esHost,
|
||||
bool? conectado,
|
||||
int? ultimaActividadMs,
|
||||
}) {
|
||||
return ClienteSala(
|
||||
clientId: clientId ?? this.clientId,
|
||||
@@ -54,6 +57,7 @@ class ClienteSala {
|
||||
nombre: nombre ?? this.nombre,
|
||||
esHost: esHost ?? this.esHost,
|
||||
conectado: conectado ?? this.conectado,
|
||||
ultimaActividadMs: ultimaActividadMs ?? this.ultimaActividadMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +67,7 @@ class ClienteSala {
|
||||
'nombre': nombre,
|
||||
'esHost': esHost,
|
||||
'conectado': conectado,
|
||||
'ultimaActividadMs': ultimaActividadMs,
|
||||
};
|
||||
|
||||
factory ClienteSala.fromJson(Map<String, dynamic> json) => ClienteSala(
|
||||
@@ -71,6 +76,7 @@ class ClienteSala {
|
||||
nombre: json['nombre'] as String,
|
||||
esHost: json['esHost'] as bool? ?? false,
|
||||
conectado: json['conectado'] as bool? ?? true,
|
||||
ultimaActividadMs: (json['ultimaActividadMs'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +127,19 @@ class EstadoSalaMultijugador {
|
||||
|
||||
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) {
|
||||
return usuarios.values
|
||||
.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() {
|
||||
if (fase != FaseSalaMultijugador.lobby) {
|
||||
return const ResultadoOperacionSala.error('sala_cerrada');
|
||||
|
||||
@@ -16,7 +16,14 @@ import 'pantalla_principal.dart';
|
||||
import 'pantalla_ver_palabra.dart';
|
||||
|
||||
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
|
||||
State<PantallaCrearPartida> createState() => _PantallaCrearPartidaState();
|
||||
@@ -33,7 +40,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
|
||||
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) => [
|
||||
l10n.noLimit,
|
||||
@@ -295,42 +309,70 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Modo de juego
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
if (!widget.bloquearModo) ...[
|
||||
// Modo de juego
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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: [
|
||||
Text(
|
||||
l10n.gameMode,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
Icon(
|
||||
_modoMultimovil ? Icons.devices : Icons.phone_android,
|
||||
color: TemaApp.colorNaranja,
|
||||
),
|
||||
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);
|
||||
},
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_modoMultimovil
|
||||
? 'Partida multidispositivo'
|
||||
: 'Partida en este dispositivo',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// Categoría
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -367,13 +409,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Jugadores
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!_modoMultimovil) ...[
|
||||
// Jugadores
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -429,11 +472,12 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
||||
dense: true,
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Configuración de partida
|
||||
Card(
|
||||
|
||||
@@ -4,13 +4,14 @@ import 'package:provider/provider.dart';
|
||||
import '../modelos/inicio_partida_multijugador.dart';
|
||||
import '../modelos/palabra.dart';
|
||||
import '../modelos/snapshot_partida_online.dart';
|
||||
import '../servicios/servicio_historial_partidas.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_notas_online.dart';
|
||||
import 'pantalla_principal.dart';
|
||||
import 'pantalla_revision_palabra.dart';
|
||||
|
||||
class PantallaFinPartidaOnline extends StatelessWidget {
|
||||
class PantallaFinPartidaOnline extends StatefulWidget {
|
||||
final SnapshotPartidaOnline snapshot;
|
||||
final List<JugadorInicioPartida> jugadoresControlados;
|
||||
final String? pistaCategoria;
|
||||
@@ -22,11 +23,33 @@ class PantallaFinPartidaOnline extends StatelessWidget {
|
||||
this.pistaCategoria,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PantallaFinPartidaOnline> createState() =>
|
||||
_PantallaFinPartidaOnlineState();
|
||||
}
|
||||
|
||||
class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
|
||||
bool _guardada = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final snapshot = widget.snapshot;
|
||||
final jugadoresControlados = widget.jugadoresControlados;
|
||||
final pistaCategoria = widget.pistaCategoria;
|
||||
final ganaronJugadores = snapshot.ganador == 'jugadores';
|
||||
|
||||
if (!_guardada && snapshot.ganador != null) {
|
||||
_guardada = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context
|
||||
.read<ServicioHistorialPartidas>()
|
||||
.guardarSnapshotOnline(snapshot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.gameOver),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../modelos/inicio_partida_multijugador.dart';
|
||||
import '../modelos/jugador.dart';
|
||||
import '../modelos/partida.dart';
|
||||
import '../modelos/snapshot_partida_online.dart';
|
||||
import '../servicios/servicio_historial_partidas.dart';
|
||||
import '../servicios/servicio_nearby.dart';
|
||||
import '../tema/componentes_farolero.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
@@ -29,6 +30,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
Timer? _timer;
|
||||
int _segundosRestantes = 0;
|
||||
bool _hostListo = false;
|
||||
bool _partidaOnlineGuardada = false;
|
||||
String? _primerTurnoId;
|
||||
String? _primerTurnoNombre;
|
||||
final Map<String, bool> _clientesListos = {};
|
||||
@@ -200,6 +202,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAvisoClientesDesconectados(context, nearby),
|
||||
_buildFaseIndicator(context, partida.fase, l10n),
|
||||
const SizedBox(height: 16),
|
||||
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(
|
||||
BuildContext context,
|
||||
FaseJuego fase,
|
||||
@@ -1115,12 +1170,21 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
estado.comprobarFinPartida();
|
||||
await nearby.enviarFinPartida(
|
||||
_snapshot(fase: 'finPartida', revelarFinal: true).toJson(),
|
||||
);
|
||||
final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true)
|
||||
.toJson();
|
||||
await _guardarHistorialOnlineHost(context);
|
||||
await nearby.enviarFinPartida(snapshotFinal);
|
||||
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 {
|
||||
final estado = context.read<EstadoJuego>();
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
@@ -1170,9 +1234,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
||||
final acierto = estado.intentarAdivinar(intento);
|
||||
if (acierto) {
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
await nearby.enviarFinPartida(
|
||||
_snapshot(fase: 'finPartida', revelarFinal: true).toJson(),
|
||||
);
|
||||
final snapshotFinal = _snapshot(fase: 'finPartida', revelarFinal: true)
|
||||
.toJson();
|
||||
await _guardarHistorialOnlineHost(context);
|
||||
await nearby.enviarFinPartida(snapshotFinal);
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../servicios/servicio_perfil_usuario.dart';
|
||||
import '../tema/componentes_farolero.dart';
|
||||
import '../tema/tema_app.dart';
|
||||
import 'pantalla_ajustes.dart';
|
||||
import 'pantalla_crear_partida.dart';
|
||||
import 'pantalla_seleccion_modo_juego.dart';
|
||||
import 'pantalla_historial.dart';
|
||||
import 'pantalla_reglas.dart';
|
||||
import 'pantalla_unirse.dart';
|
||||
@@ -94,7 +94,7 @@ class PantallaPrincipal extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
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),
|
||||
|
||||
144
lib/pantallas/pantalla_seleccion_modo_juego.dart
Normal file
144
lib/pantallas/pantalla_seleccion_modo_juego.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,12 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
|
||||
if (!mounted) return;
|
||||
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) {
|
||||
setState(() {
|
||||
@@ -337,11 +342,14 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
});
|
||||
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
final perfil = context.read<ServicioPerfilUsuario>().perfil;
|
||||
// Parar discovery antes de conectar
|
||||
await nearby.pararBusqueda();
|
||||
final ok = await nearby.conectarAHost(
|
||||
endpointId,
|
||||
_nombreController.text.trim(),
|
||||
miNick: perfil.nick,
|
||||
miAvatar: perfil.avatarAsset,
|
||||
);
|
||||
|
||||
if (!ok && mounted) {
|
||||
@@ -381,7 +389,12 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
||||
// Iniciar búsqueda para que Nearby encuentre al host
|
||||
final nearby = context.read<ServicioNearby>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/partida.dart';
|
||||
import '../modelos/snapshot_partida_online.dart';
|
||||
|
||||
class ResultadoPartidaGuardado {
|
||||
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() => {
|
||||
'id': id,
|
||||
'fecha': fecha.toIso8601String(),
|
||||
@@ -99,6 +119,16 @@ class ServicioHistorialPartidas extends ChangeNotifier {
|
||||
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 {
|
||||
_partidas.clear();
|
||||
await _persistir();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nearby_connections/nearby_connections.dart';
|
||||
import '../modelos/inicio_partida_multijugador.dart';
|
||||
@@ -82,11 +83,14 @@ class ServicioNearby extends ChangeNotifier {
|
||||
String? _miClientId;
|
||||
String? _nombreSala;
|
||||
String? _miNombre;
|
||||
String? _miNick;
|
||||
String? _miAvatar;
|
||||
|
||||
final Map<String, JugadorConectado> _jugadores = {};
|
||||
final List<OnMensajeCallback> _listeners = [];
|
||||
final Map<String, String> _hostsEncontrados = {};
|
||||
final Map<String, Usuario> _usuariosPool = {};
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
String? _palabraRecibida;
|
||||
bool? _soyImpostor;
|
||||
@@ -273,8 +277,14 @@ class ServicioNearby extends ChangeNotifier {
|
||||
|
||||
// ==================== CLIENTE ====================
|
||||
|
||||
Future<bool> buscarHosts(String miNombre) async {
|
||||
Future<bool> buscarHosts(
|
||||
String miNombre, {
|
||||
String? miNick,
|
||||
String? miAvatar,
|
||||
}) async {
|
||||
_miNombre = miNombre;
|
||||
_miNick = miNick;
|
||||
_miAvatar = miAvatar;
|
||||
|
||||
try {
|
||||
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 {
|
||||
await Nearby().requestConnection(
|
||||
miNombre,
|
||||
@@ -332,11 +350,16 @@ class ServicioNearby extends ChangeNotifier {
|
||||
} else {
|
||||
_hostEndpointId = endpointId;
|
||||
_conectado = true;
|
||||
_iniciarHeartbeatCliente();
|
||||
enviarMensaje(
|
||||
endpointId,
|
||||
MensajeP2P(
|
||||
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 {
|
||||
_conectado = false;
|
||||
_hostEndpointId = null;
|
||||
_heartbeatTimer?.cancel();
|
||||
}
|
||||
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(
|
||||
String endpointId,
|
||||
String endpointName,
|
||||
@@ -426,6 +469,10 @@ class ServicioNearby extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) {
|
||||
final cliente = _estadoSala?.clientePorEndpoint(endpointId);
|
||||
if (cliente != null) {
|
||||
_estadoSala?.registrarActividadCliente(cliente.clientId);
|
||||
}
|
||||
switch (mensaje.tipo) {
|
||||
case TipoMensaje.unirse:
|
||||
_registrarClienteRemoto(endpointId, mensaje);
|
||||
@@ -466,6 +513,8 @@ class ServicioNearby extends ChangeNotifier {
|
||||
|
||||
void _registrarClienteRemoto(String endpointId, MensajeP2P mensaje) {
|
||||
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;
|
||||
_jugadores[endpointId] = JugadorConectado(
|
||||
endpointId: endpointId,
|
||||
@@ -474,6 +523,12 @@ class ServicioNearby extends ChangeNotifier {
|
||||
_estadoSala?.registrarCliente(
|
||||
ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre),
|
||||
);
|
||||
_crearUsuarioAutomaticoCliente(
|
||||
clientId: clientId,
|
||||
nombre: nombre,
|
||||
nick: nick,
|
||||
avatar: avatar,
|
||||
);
|
||||
|
||||
enviarMensaje(
|
||||
endpointId,
|
||||
@@ -495,6 +550,42 @@ class ServicioNearby extends ChangeNotifier {
|
||||
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) {
|
||||
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
|
||||
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 ====================
|
||||
|
||||
Future<void> enviarInicioPartida({
|
||||
@@ -851,6 +958,7 @@ class ServicioNearby extends ChangeNotifier {
|
||||
// ==================== LIMPIEZA ====================
|
||||
|
||||
Future<void> desconectar() async {
|
||||
_heartbeatTimer?.cancel();
|
||||
try {
|
||||
await Nearby().stopAllEndpoints();
|
||||
if (_anunciando) await Nearby().stopAdvertising();
|
||||
@@ -869,6 +977,8 @@ class ServicioNearby extends ChangeNotifier {
|
||||
_miClientId = null;
|
||||
_nombreSala = null;
|
||||
_miNombre = null;
|
||||
_miNick = null;
|
||||
_miAvatar = null;
|
||||
_palabraRecibida = null;
|
||||
_soyImpostor = null;
|
||||
_faseActual = null;
|
||||
@@ -877,6 +987,7 @@ class ServicioNearby extends ChangeNotifier {
|
||||
_jugadores.clear();
|
||||
_hostsEncontrados.clear();
|
||||
_usuariosPool.clear();
|
||||
_heartbeatTimer = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -901,6 +1012,7 @@ class ServicioNearby extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_heartbeatTimer?.cancel();
|
||||
desconectar();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user