Fixes
- SnapshotPartidaOnline no longer serializes esImpostor unless the game is
over. Every phase change was broadcasting the impostor roles in clear to
all clients.
- The impostor clue travels in its own field and only when the game enables
it. It used to be sent as the category key ("todas" when no category was
picked) and was overwritten by each phase snapshot.
- Eliminated players can no longer vote nor be voted for, on both the client
screen and the host listener.
- Nearby listeners are removed on dispose; votes are no longer dispatched
twice; votacionResultado no longer triggers two navigations.
- _jugadoresHostControlados handed the secret word to the host's own
impostors in the payload.
- Impostor cap unified as maxImpostoresPara(n) for both modes, and the
silent clamp in multi-device now reports the effective number.
- palabraAleatoria uses Random.secure.
Impostors know each other
- ConfigPartida.impostoresSeConocen, on by default. companerosImpostores is
nullable: null means nothing to show, an empty list means "you are the
only impostor".
Reconnection
- IdentidadDispositivo persists a stable device id; the host uses it as the
clientId so a phone that drops and returns is the same client. Falls back
to the endpointId for clients that do not send one.
- Usuario.absorbidoDe records the original owner when the host takes over a
disconnected player, and they are handed back automatically on return.
- New solicitarResync/resync messages: the host replies with the current
phase plus that client's own players, and the client jumps straight to the
running phase.
- Readiness is keyed by clientId instead of endpointId, and a disconnected
client no longer blocks the round.
- The retry gives up after three minutes, only reconnects to the host it
belonged to, and is cancelled correctly while shutting down.
Per-word clue support
- The word bank loader accepts both the old list-of-strings format and the
new one carrying a clue per word, falling back to the category clue.
Not verified on real devices: Nearby reconnection timing still needs two
Android phones.
1224 lines
36 KiB
Dart
1224 lines
36 KiB
Dart
import 'dart:async';
|
|
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';
|
|
import 'identidad_dispositivo.dart';
|
|
|
|
/// Tipos de mensajes en el protocolo P2P.
|
|
enum TipoMensaje {
|
|
salaInfo,
|
|
partidaInicio,
|
|
fase,
|
|
votacionResultado,
|
|
partidaFin,
|
|
unirse,
|
|
voto,
|
|
listo,
|
|
ping,
|
|
jugadorDesconectado,
|
|
clienteRegistrado,
|
|
estadoSala,
|
|
crearUsuario,
|
|
seleccionarUsuario,
|
|
liberarUsuario,
|
|
eliminarUsuario,
|
|
errorOperacion,
|
|
usuarioNuevo,
|
|
// Reconexión: el cliente pide el estado de la partida en curso y el host se
|
|
// lo devuelve completo.
|
|
solicitarResync,
|
|
resync,
|
|
// Compatibilidad con versiones previas del protocolo.
|
|
usuarioEliminado,
|
|
usuariosActualizados,
|
|
}
|
|
|
|
/// Mensaje del protocolo P2P entre dispositivos.
|
|
class MensajeP2P {
|
|
final TipoMensaje tipo;
|
|
final Map<String, dynamic> datos;
|
|
|
|
MensajeP2P({required this.tipo, required this.datos});
|
|
|
|
String toJson() => json.encode({'tipo': tipo.name, 'datos': datos});
|
|
|
|
factory MensajeP2P.fromJson(String jsonStr) {
|
|
final mapa = json.decode(jsonStr) as Map<String, dynamic>;
|
|
return MensajeP2P(
|
|
tipo: TipoMensaje.values.firstWhere((t) => t.name == mapa['tipo']),
|
|
datos: mapa['datos'] as Map<String, dynamic>,
|
|
);
|
|
}
|
|
|
|
Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson()));
|
|
}
|
|
|
|
/// 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;
|
|
bool listo;
|
|
|
|
JugadorConectado({
|
|
required this.endpointId,
|
|
required this.nombre,
|
|
this.listo = false,
|
|
});
|
|
}
|
|
|
|
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;
|
|
bool _buscando = false;
|
|
bool _anunciando = false;
|
|
String? _miEndpointId;
|
|
String? _hostEndpointId;
|
|
String? _roomId;
|
|
String? _miClientId;
|
|
String? _nombreSala;
|
|
String? _miNombre;
|
|
String? _miNick;
|
|
String? _miAvatar;
|
|
int _miFuego = 0;
|
|
List<String> _miMedallas = const [];
|
|
|
|
final Map<String, JugadorConectado> _jugadores = {};
|
|
final List<OnMensajeCallback> _listeners = [];
|
|
final Map<String, String> _hostsEncontrados = {};
|
|
final Map<String, Usuario> _usuariosPool = {};
|
|
Timer? _heartbeatTimer;
|
|
|
|
String? _miDeviceId;
|
|
bool _reconectando = false;
|
|
bool _partidaEnCursoAlEntrar = false;
|
|
bool _cerrando = false;
|
|
String? _nombreHostConectado;
|
|
Timer? _limiteReconexion;
|
|
|
|
/// Cuánto se insiste en volver antes de rendirse. Sin tope, un móvil cuyo
|
|
/// host se ha ido se quedaría escaneando y gastando batería para siempre.
|
|
static const _ventanaReconexion = Duration(minutes: 3);
|
|
|
|
String? _palabraRecibida;
|
|
bool? _soyImpostor;
|
|
String? _faseActual;
|
|
Map<String, dynamic>? _datosPartida;
|
|
EstadoSalaMultijugador? _estadoSala;
|
|
|
|
bool get esHost => _esHost;
|
|
bool get conectado => _conectado;
|
|
|
|
/// El cliente perdió la conexión y está intentando volver por su cuenta.
|
|
bool get reconectando => _reconectando;
|
|
|
|
/// Al registrarse, el host indicó que ya había una partida empezada.
|
|
bool get partidaEnCursoAlEntrar => _partidaEnCursoAlEntrar;
|
|
String? get miDeviceId => _miDeviceId;
|
|
bool get buscando => _buscando;
|
|
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);
|
|
|
|
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);
|
|
}
|
|
|
|
void onMensaje(OnMensajeCallback callback) {
|
|
_listeners.add(callback);
|
|
}
|
|
|
|
void removeMensajeListener(OnMensajeCallback callback) {
|
|
_listeners.remove(callback);
|
|
}
|
|
|
|
void _notificarMensaje(String endpointId, MensajeP2P mensaje) {
|
|
for (final listener in _listeners) {
|
|
listener(endpointId, mensaje);
|
|
}
|
|
}
|
|
|
|
// ==================== USER POOL / SALA ====================
|
|
|
|
void agregarUsuario(Usuario usuario) {
|
|
_usuariosPool[usuario.id] = usuario;
|
|
_estadoSala?.usuarios[usuario.id] = usuario;
|
|
notifyListeners();
|
|
}
|
|
|
|
void eliminarUsuario(String usuarioId) {
|
|
_usuariosPool.remove(usuarioId);
|
|
_estadoSala?.usuarios.remove(usuarioId);
|
|
notifyListeners();
|
|
}
|
|
|
|
Usuario? getUsuario(String usuarioId) {
|
|
return _estadoSala?.usuarios[usuarioId] ?? _usuariosPool[usuarioId];
|
|
}
|
|
|
|
void sincronizarUsuarios(List<Usuario> usuarios) {
|
|
_usuariosPool.clear();
|
|
for (final usuario in usuarios) {
|
|
_usuariosPool[usuario.id] = usuario;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
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 ?? '',
|
|
nombre: _miNombre!,
|
|
listo: true,
|
|
);
|
|
}
|
|
|
|
// ==================== HOST ====================
|
|
|
|
Future<bool> iniciarHost(
|
|
String nombreSala,
|
|
String miNombre, {
|
|
String? miNick,
|
|
String? miAvatar,
|
|
int miFuego = 0,
|
|
List<String> miMedallas = const [],
|
|
}) async {
|
|
if (_conectado ||
|
|
_anunciando ||
|
|
_buscando ||
|
|
_estadoSala != null ||
|
|
_jugadores.isNotEmpty) {
|
|
await desconectar();
|
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
|
}
|
|
|
|
_nombreSala = nombreSala;
|
|
_miNombre = miNombre;
|
|
_roomId = DateTime.now().microsecondsSinceEpoch.toString();
|
|
_miClientId = _hostClientId;
|
|
_miDeviceId = await IdentidadDispositivo.obtener();
|
|
_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,
|
|
nick: miNick,
|
|
avatar: miAvatar,
|
|
foto: miAvatar,
|
|
creadoPorClienteId: _hostClientId,
|
|
clienteIdSeleccionado: _hostClientId,
|
|
fuego: miFuego,
|
|
medallas: miMedallas,
|
|
);
|
|
_estadoSala!.crearUsuario(usuarioHost);
|
|
_sincronizarPoolDesdeSala();
|
|
|
|
try {
|
|
final resultado = await Nearby().startAdvertising(
|
|
miNombre,
|
|
Strategy.P2P_STAR,
|
|
onConnectionInitiated: _onConexionIniciada,
|
|
onConnectionResult: _onResultadoConexion,
|
|
onDisconnected: _onDesconexion,
|
|
serviceId: _serviceId,
|
|
);
|
|
|
|
if (resultado) {
|
|
_esHost = true;
|
|
_anunciando = true;
|
|
_conectado = true;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
await desconectar();
|
|
return false;
|
|
} catch (e) {
|
|
debugPrint('Error iniciando host: $e');
|
|
await desconectar();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ==================== CLIENTE ====================
|
|
|
|
Future<bool> buscarHosts(
|
|
String miNombre, {
|
|
String? miNick,
|
|
String? miAvatar,
|
|
int miFuego = 0,
|
|
List<String> miMedallas = const [],
|
|
}) async {
|
|
_miNombre = miNombre;
|
|
_miNick = miNick;
|
|
_miAvatar = miAvatar;
|
|
_miFuego = miFuego;
|
|
_miMedallas = miMedallas;
|
|
_miDeviceId ??= await IdentidadDispositivo.obtener();
|
|
|
|
try {
|
|
final resultado = await Nearby().startDiscovery(
|
|
miNombre,
|
|
Strategy.P2P_STAR,
|
|
onEndpointFound: _onEndpointEncontrado,
|
|
onEndpointLost: _onEndpointPerdido,
|
|
serviceId: _serviceId,
|
|
);
|
|
|
|
if (resultado) {
|
|
_buscando = true;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
debugPrint('Error buscando hosts: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> conectarAHost(
|
|
String endpointId,
|
|
String miNombre, {
|
|
String? miNick,
|
|
String? miAvatar,
|
|
int miFuego = 0,
|
|
List<String> miMedallas = const [],
|
|
}) async {
|
|
_miNombre = miNombre;
|
|
_miNick = miNick;
|
|
_miAvatar = miAvatar;
|
|
_miFuego = miFuego;
|
|
_miMedallas = miMedallas;
|
|
_miDeviceId ??= await IdentidadDispositivo.obtener();
|
|
_nombreHostConectado = _hostsEncontrados[endpointId];
|
|
try {
|
|
await Nearby().requestConnection(
|
|
miNombre,
|
|
endpointId,
|
|
onConnectionInitiated: _onConexionIniciada,
|
|
onConnectionResult: _onResultadoConexion,
|
|
onDisconnected: _onDesconexion,
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('Error conectando a host: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ==================== CALLBACKS NEARBY ====================
|
|
|
|
void _onConexionIniciada(String endpointId, ConnectionInfo info) {
|
|
debugPrint('Conexion iniciada con $endpointId: ${info.endpointName}');
|
|
Nearby().acceptConnection(
|
|
endpointId,
|
|
onPayLoadRecieved: _onPayloadRecibido,
|
|
onPayloadTransferUpdate: _onPayloadUpdate,
|
|
);
|
|
}
|
|
|
|
void _onResultadoConexion(String endpointId, Status status) {
|
|
debugPrint('Resultado conexion $endpointId: $status');
|
|
if (status == Status.CONNECTED) {
|
|
if (_esHost) {
|
|
debugPrint('Cliente conectado: $endpointId');
|
|
} else {
|
|
_hostEndpointId = endpointId;
|
|
_conectado = true;
|
|
_reconectando = false;
|
|
_buscando = false;
|
|
_limiteReconexion?.cancel();
|
|
_limiteReconexion = null;
|
|
_iniciarHeartbeatCliente();
|
|
enviarMensaje(
|
|
endpointId,
|
|
MensajeP2P(
|
|
tipo: TipoMensaje.unirse,
|
|
datos: {
|
|
'nombre': _miNombre ?? 'Jugador',
|
|
if (_miNick != null) 'nick': _miNick,
|
|
if (_miAvatar != null) 'avatar': _miAvatar,
|
|
'fuego': _miFuego,
|
|
'medallas': _miMedallas,
|
|
if (_miDeviceId != null) 'deviceId': _miDeviceId,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
notifyListeners();
|
|
} else {
|
|
debugPrint('Conexion fallida con $endpointId');
|
|
}
|
|
}
|
|
|
|
void _onDesconexion(String 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) {
|
|
enviarATodos(
|
|
MensajeP2P(
|
|
tipo: TipoMensaje.jugadorDesconectado,
|
|
datos: {'nombre': jugador.nombre, 'endpointId': endpointId},
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
_conectado = false;
|
|
_hostEndpointId = null;
|
|
_heartbeatTimer?.cancel();
|
|
// Conservamos clientId y deviceId: son la llave para que el host nos
|
|
// reconozca cuando volvamos.
|
|
_iniciarReconexionCliente();
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Relanza el descubrimiento tras una caída para volver a la misma sala.
|
|
Future<void> _iniciarReconexionCliente() async {
|
|
if (_esHost || _cerrando || _miNombre == null) return;
|
|
_reconectando = true;
|
|
_limiteReconexion?.cancel();
|
|
_limiteReconexion = Timer(_ventanaReconexion, cancelarReconexion);
|
|
notifyListeners();
|
|
|
|
try {
|
|
await Nearby().stopDiscovery();
|
|
} catch (_) {}
|
|
// Entre el await y aquí el usuario puede haber salido.
|
|
if (_cerrando || !_reconectando) return;
|
|
try {
|
|
_buscando = await Nearby().startDiscovery(
|
|
_miNombre!,
|
|
Strategy.P2P_STAR,
|
|
onEndpointFound: _onEndpointEncontrado,
|
|
onEndpointLost: _onEndpointPerdido,
|
|
serviceId: _serviceId,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Error reiniciando descubrimiento: $e');
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Corta el reintento automático (por ejemplo si el usuario sale a menú).
|
|
Future<void> cancelarReconexion() async {
|
|
_limiteReconexion?.cancel();
|
|
_limiteReconexion = null;
|
|
if (!_reconectando) return;
|
|
_reconectando = false;
|
|
await pararBusqueda();
|
|
}
|
|
|
|
|
|
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,
|
|
String serviceId,
|
|
) {
|
|
debugPrint('Host encontrado: $endpointName ($endpointId)');
|
|
_hostsEncontrados[endpointId] = endpointName;
|
|
// Volvemos solos, pero solo a nuestro host: si hay otra partida cerca no
|
|
// queremos aterrizar en la sala equivocada.
|
|
final esNuestroHost =
|
|
_nombreHostConectado == null || _nombreHostConectado == endpointName;
|
|
if (_reconectando && !_conectado && esNuestroHost) {
|
|
_reconectarA(endpointId);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _reconectarA(String endpointId) async {
|
|
try {
|
|
await Nearby().requestConnection(
|
|
_miNombre ?? 'Jugador',
|
|
endpointId,
|
|
onConnectionInitiated: _onConexionIniciada,
|
|
onConnectionResult: _onResultadoConexion,
|
|
onDisconnected: _onDesconexion,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Error reconectando a $endpointId: $e');
|
|
}
|
|
}
|
|
|
|
void _onEndpointPerdido(String? endpointId) {
|
|
debugPrint('Endpoint perdido: $endpointId');
|
|
if (endpointId != null) {
|
|
_hostsEncontrados.remove(endpointId);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> pararBusqueda() async {
|
|
try {
|
|
await Nearby().stopDiscovery();
|
|
} catch (_) {}
|
|
_buscando = false;
|
|
_hostsEncontrados.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
void _onPayloadRecibido(String endpointId, Payload payload) {
|
|
if (payload.type == PayloadType.BYTES && payload.bytes != null) {
|
|
try {
|
|
final jsonStr = utf8.decode(payload.bytes!);
|
|
final mensaje = MensajeP2P.fromJson(jsonStr);
|
|
_procesarMensaje(endpointId, mensaje);
|
|
} catch (e) {
|
|
debugPrint('Error procesando payload: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {}
|
|
|
|
// ==================== PROCESAMIENTO DE MENSAJES ====================
|
|
|
|
void _procesarMensaje(String endpointId, MensajeP2P mensaje) {
|
|
debugPrint('Mensaje de $endpointId: ${mensaje.tipo.name}');
|
|
|
|
if (_esHost) {
|
|
_procesarMensajeHost(endpointId, mensaje);
|
|
} else {
|
|
_procesarMensajeCliente(endpointId, mensaje);
|
|
}
|
|
|
|
_notificarMensaje(endpointId, mensaje);
|
|
}
|
|
|
|
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);
|
|
break;
|
|
case TipoMensaje.voto:
|
|
// El reparto a los listeners lo hace _procesarMensaje al final; hacerlo
|
|
// aquí también entregaba cada voto dos veces.
|
|
break;
|
|
case TipoMensaje.listo:
|
|
final jugador = _jugadores[endpointId];
|
|
if (jugador != null) {
|
|
jugador.listo = true;
|
|
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;
|
|
case TipoMensaje.solicitarResync:
|
|
// Lo resuelve la pantalla gestora, que es quien tiene la partida.
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
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 fuego = (mensaje.datos['fuego'] as num?)?.toInt() ?? 0;
|
|
final medallas = (mensaje.datos['medallas'] as List<dynamic>? ?? const [])
|
|
.map((valor) => valor.toString())
|
|
.toList();
|
|
|
|
// El clientId debe sobrevivir a la reconexión, y el endpointId no lo hace:
|
|
// Nearby asigna uno nuevo cada vez. Con clientes antiguos que no mandan
|
|
// deviceId se cae al comportamiento de siempre.
|
|
final deviceId = mensaje.datos['deviceId'] as String?;
|
|
final clientId = deviceId ?? endpointId;
|
|
final sala = _estadoSala;
|
|
final esReconexion = sala?.esReconexion(clientId) ?? false;
|
|
|
|
// Si el mismo dispositivo tenía otro endpoint abierto, se descarta.
|
|
_jugadores.removeWhere(
|
|
(id, _) =>
|
|
id != endpointId &&
|
|
sala?.clientePorEndpoint(id)?.clientId == clientId,
|
|
);
|
|
_jugadores[endpointId] = JugadorConectado(
|
|
endpointId: endpointId,
|
|
nombre: nombre,
|
|
);
|
|
sala?.registrarCliente(
|
|
ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre),
|
|
);
|
|
|
|
if (esReconexion) {
|
|
// Vuelve un móvil que se había caído: recupera los jugadores que el host
|
|
// le había absorbido mientras tanto.
|
|
sala?.devolverUsuariosAbsorbidos(clientId);
|
|
} else {
|
|
_crearUsuarioAutomaticoCliente(
|
|
clientId: clientId,
|
|
nombre: nombre,
|
|
nick: nick,
|
|
avatar: avatar,
|
|
fuego: fuego,
|
|
medallas: medallas,
|
|
);
|
|
}
|
|
|
|
final partidaEnCurso = sala?.fase == FaseSalaMultijugador.enPartida;
|
|
|
|
enviarMensaje(
|
|
endpointId,
|
|
MensajeP2P(
|
|
tipo: TipoMensaje.clienteRegistrado,
|
|
datos: {
|
|
'clientId': clientId,
|
|
'sala': _nombreSala,
|
|
'roomId': _roomId,
|
|
'reconexion': esReconexion,
|
|
'partidaEnCurso': partidaEnCurso,
|
|
'jugadores': _jugadores.values
|
|
.map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId})
|
|
.toList(),
|
|
'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(),
|
|
if (sala != null) 'estadoSala': sala.toJson(),
|
|
},
|
|
),
|
|
);
|
|
_broadcastEstadoSala();
|
|
notifyListeners();
|
|
}
|
|
|
|
void _crearUsuarioAutomaticoCliente({
|
|
required String clientId,
|
|
required String nombre,
|
|
String? nick,
|
|
String? avatar,
|
|
int fuego = 0,
|
|
List<String> medallas = const [],
|
|
}) {
|
|
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,
|
|
fuego: fuego,
|
|
medallas: medallas,
|
|
);
|
|
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) {
|
|
final nuevoUsuario = Usuario.fromJson(usuarioJson);
|
|
_usuariosPool[nuevoUsuario.id] = nuevoUsuario;
|
|
_estadoSala?.usuarios[nuevoUsuario.id] = nuevoUsuario;
|
|
if (_esHost) {
|
|
_broadcastEstadoSala();
|
|
}
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _handleUsuariosActualizados(MensajeP2P mensaje) {
|
|
final usuariosJson = mensaje.datos['usuarios'] as List<dynamic>?;
|
|
if (usuariosJson != null) {
|
|
_usuariosPool.clear();
|
|
for (final u in usuariosJson) {
|
|
final usuario = Usuario.fromJson(u as Map<String, dynamic>);
|
|
_usuariosPool[usuario.id] = usuario;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
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;
|
|
final usuariosData = mensaje.datos['usuarios'] as List<dynamic>?;
|
|
if (usuariosData != null) {
|
|
_usuariosPool.clear();
|
|
for (final u in usuariosData) {
|
|
final usuario = Usuario.fromJson(u as Map<String, dynamic>);
|
|
_usuariosPool[usuario.id] = usuario;
|
|
}
|
|
}
|
|
notifyListeners();
|
|
break;
|
|
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));
|
|
}
|
|
_partidaEnCursoAlEntrar =
|
|
mensaje.datos['partidaEnCurso'] as bool? ?? false;
|
|
if (_partidaEnCursoAlEntrar) {
|
|
// Entramos con la partida ya empezada: pedimos el estado completo en
|
|
// vez de quedarnos esperando en el lobby.
|
|
solicitarResync();
|
|
}
|
|
notifyListeners();
|
|
break;
|
|
case TipoMensaje.resync:
|
|
_faseActual = mensaje.datos['fase'] as String?;
|
|
_datosPartida = mensaje.datos;
|
|
final pista = mensaje.datos['pistaImpostor'] as String?;
|
|
if (pista != null) _datosPartida!['pistaImpostor'] = pista;
|
|
notifyListeners();
|
|
break;
|
|
case TipoMensaje.estadoSala:
|
|
_sincronizarSala(EstadoSalaMultijugador.fromJson(mensaje.datos));
|
|
notifyListeners();
|
|
break;
|
|
case TipoMensaje.partidaInicio:
|
|
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:
|
|
case TipoMensaje.partidaFin:
|
|
case TipoMensaje.errorOperacion:
|
|
_datosPartida = mensaje.datos;
|
|
notifyListeners();
|
|
break;
|
|
case TipoMensaje.jugadorDesconectado:
|
|
notifyListeners();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ==================== ENVIO ====================
|
|
|
|
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
|
|
try {
|
|
await Nearby().sendBytesPayload(endpointId, mensaje.toBytes());
|
|
} catch (e) {
|
|
debugPrint('Error enviando a $endpointId: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> enviarATodos(MensajeP2P mensaje) async {
|
|
for (final id in _jugadores.keys) {
|
|
await enviarMensaje(id, mensaje);
|
|
}
|
|
}
|
|
|
|
Future<void> crearUsuarioSala(
|
|
String nombre, {
|
|
bool seleccionar = true,
|
|
String? nick,
|
|
String? avatar,
|
|
int fuego = 0,
|
|
List<String> medallas = const [],
|
|
}) async {
|
|
final nombreLimpio = nombre.trim();
|
|
if (nombreLimpio.isEmpty) return;
|
|
final clientId = _miClientId;
|
|
final usuario = Usuario(
|
|
id: 'u-${DateTime.now().microsecondsSinceEpoch}',
|
|
nombre: nombreLimpio,
|
|
nick: nick,
|
|
avatar: avatar,
|
|
foto: avatar,
|
|
creadoPorClienteId: clientId,
|
|
fuego: fuego,
|
|
medallas: medallas,
|
|
);
|
|
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},
|
|
),
|
|
);
|
|
}
|
|
|
|
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({
|
|
required String palabraSecreta,
|
|
required String categoria,
|
|
required Map<String, bool> impostores,
|
|
}) async {
|
|
for (final entry in _jugadores.entries) {
|
|
final esImpostor = impostores[entry.key] ?? false;
|
|
await enviarMensaje(
|
|
entry.key,
|
|
MensajeP2P(
|
|
tipo: TipoMensaje.partidaInicio,
|
|
datos: {
|
|
'palabra': esImpostor ? null : palabraSecreta,
|
|
'esImpostor': esImpostor,
|
|
'categoria': categoria,
|
|
'numJugadores': _jugadores.length + 1,
|
|
'roomId': _roomId,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> enviarInicioPartidaMulti({
|
|
required List<AsignacionJugador> asignaciones,
|
|
required String palabraSecreta,
|
|
required String categoria,
|
|
required Map<String, bool> impostoresPorJugadorId,
|
|
required List<Map<String, dynamic>> jugadoresTodos,
|
|
bool impostoresSeConocen = false,
|
|
String? pistaImpostor,
|
|
}) async {
|
|
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
|
|
asignaciones: asignaciones,
|
|
palabraSecreta: palabraSecreta,
|
|
categoria: categoria,
|
|
impostoresPorJugadorId: impostoresPorJugadorId,
|
|
impostoresSeConocen: impostoresSeConocen,
|
|
);
|
|
|
|
for (final payload in payloads.values) {
|
|
final endpointId = payload.endpointId;
|
|
if (endpointId == null) continue;
|
|
final datos = payload.toJson();
|
|
datos['jugadoresTodos'] = jugadoresTodos;
|
|
datos['roomId'] = _roomId;
|
|
// Solo viaja si la partida tiene la pista activada.
|
|
if (pistaImpostor != null) datos['pistaImpostor'] = pistaImpostor;
|
|
await enviarMensaje(
|
|
endpointId,
|
|
MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// El cliente pide al host el estado completo de la partida en curso.
|
|
Future<void> solicitarResync() async {
|
|
final hostId = _hostEndpointId;
|
|
if (_esHost || hostId == null) return;
|
|
await enviarMensaje(
|
|
hostId,
|
|
MensajeP2P(
|
|
tipo: TipoMensaje.solicitarResync,
|
|
datos: {if (_miClientId != null) 'clientId': _miClientId},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// El host responde a un cliente concreto con el estado completo.
|
|
Future<void> enviarResync(
|
|
String endpointId,
|
|
Map<String, dynamic> datos,
|
|
) async {
|
|
await enviarMensaje(
|
|
endpointId,
|
|
MensajeP2P(tipo: TipoMensaje.resync, datos: datos),
|
|
);
|
|
}
|
|
|
|
Future<void> enviarCambioFase(
|
|
String fase, [
|
|
Map<String, dynamic>? extra,
|
|
]) async {
|
|
final datos = {'fase': fase, ...?extra};
|
|
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
|
|
}
|
|
|
|
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
|
|
await enviarATodos(
|
|
MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado),
|
|
);
|
|
}
|
|
|
|
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
|
|
await enviarATodos(
|
|
MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado),
|
|
);
|
|
}
|
|
|
|
// ==================== LIMPIEZA ====================
|
|
|
|
Future<void> desconectar() async {
|
|
_cerrando = true;
|
|
_heartbeatTimer?.cancel();
|
|
_limiteReconexion?.cancel();
|
|
_limiteReconexion = null;
|
|
try {
|
|
await Nearby().stopAllEndpoints();
|
|
if (_anunciando) await Nearby().stopAdvertising();
|
|
if (_buscando) await Nearby().stopDiscovery();
|
|
} catch (e) {
|
|
debugPrint('Error desconectando: $e');
|
|
}
|
|
|
|
_esHost = false;
|
|
_conectado = false;
|
|
_buscando = false;
|
|
_anunciando = false;
|
|
_miEndpointId = null;
|
|
_hostEndpointId = null;
|
|
_roomId = null;
|
|
_miClientId = null;
|
|
_nombreSala = null;
|
|
_miNombre = null;
|
|
_miNick = null;
|
|
_miAvatar = null;
|
|
_miFuego = 0;
|
|
_miMedallas = const [];
|
|
_palabraRecibida = null;
|
|
_soyImpostor = null;
|
|
_faseActual = null;
|
|
_datosPartida = null;
|
|
_estadoSala = null;
|
|
_jugadores.clear();
|
|
_hostsEncontrados.clear();
|
|
_usuariosPool.clear();
|
|
_heartbeatTimer = null;
|
|
_reconectando = false;
|
|
_partidaEnCursoAlEntrar = false;
|
|
_nombreHostConectado = null;
|
|
_cerrando = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
String generarDatosQR(String nombreSala) {
|
|
return json.encode({
|
|
'app': 'farolero',
|
|
'sala': nombreSala,
|
|
'host': _miNombre,
|
|
'roomId': _roomId,
|
|
});
|
|
}
|
|
|
|
static Map<String, dynamic>? parsearQR(String datos) {
|
|
try {
|
|
final mapa = json.decode(datos) as Map<String, dynamic>;
|
|
if (mapa['app'] == 'farolero') return mapa;
|
|
return null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_heartbeatTimer?.cancel();
|
|
_limiteReconexion?.cancel();
|
|
desconectar();
|
|
super.dispose();
|
|
}
|
|
}
|