feat: modo multidispositivo con Nearby Connections
- ServicioNearby completo: P2P_STAR, auto-accept, protocolo mensajes - PantallaLobbyHost: QR code + lista jugadores tiempo real - PantallaUnirse: escaneo QR + conexión + sala espera - Protocolo MensajeP2P: salaInfo, partidaInicio, fase, voto, resultado, fin - Manejo desconexiones jugador/host - l10n: nuevas keys es/en - Version bump 1.1.0+5
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nearby_connections/nearby_connections.dart';
|
||||
|
||||
/// Tipos de mensajes en el protocolo P2P
|
||||
enum TipoMensaje {
|
||||
@@ -11,6 +12,8 @@ enum TipoMensaje {
|
||||
unirse,
|
||||
voto,
|
||||
listo,
|
||||
ping,
|
||||
jugadorDesconectado,
|
||||
}
|
||||
|
||||
/// Mensaje del protocolo P2P entre dispositivos
|
||||
@@ -32,107 +35,414 @@ class MensajeP2P {
|
||||
datos: mapa['datos'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson()));
|
||||
}
|
||||
|
||||
/// Info de un jugador conectado
|
||||
class JugadorConectado {
|
||||
final String endpointId;
|
||||
final String nombre;
|
||||
bool listo;
|
||||
|
||||
JugadorConectado({
|
||||
required this.endpointId,
|
||||
required this.nombre,
|
||||
this.listo = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callback para mensajes recibidos
|
||||
typedef OnMensajeCallback = void Function(String endpointId, MensajeP2P mensaje);
|
||||
|
||||
/// Servicio para conexiones P2P usando Google Nearby Connections API.
|
||||
///
|
||||
/// Este servicio encapsula toda la lógica de Nearby Connections.
|
||||
/// Requiere dispositivos Android físicos para funcionar.
|
||||
/// En la versión actual, se provee la estructura para integración futura.
|
||||
class ServicioNearby extends ChangeNotifier {
|
||||
static const _serviceId = 'es.freetimelab.farolero';
|
||||
|
||||
bool _esHost = false;
|
||||
bool _conectado = false;
|
||||
String? _endpointId;
|
||||
final List<String> _dispositivos = [];
|
||||
bool _buscando = false;
|
||||
bool _anunciando = false;
|
||||
String? _miEndpointId;
|
||||
String? _hostEndpointId;
|
||||
String? _nombreSala;
|
||||
String? _miNombre;
|
||||
|
||||
final Map<String, JugadorConectado> _jugadores = {};
|
||||
final List<OnMensajeCallback> _listeners = [];
|
||||
|
||||
// Estado para clientes
|
||||
String? _palabraRecibida;
|
||||
bool? _soyImpostor;
|
||||
String? _faseActual;
|
||||
Map<String, dynamic>? _datosPartida;
|
||||
|
||||
bool get esHost => _esHost;
|
||||
bool get conectado => _conectado;
|
||||
String? get endpointId => _endpointId;
|
||||
List<String> get dispositivos => List.unmodifiable(_dispositivos);
|
||||
bool get buscando => _buscando;
|
||||
bool get anunciando => _anunciando;
|
||||
String? get miEndpointId => _miEndpointId;
|
||||
String? get hostEndpointId => _hostEndpointId;
|
||||
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;
|
||||
|
||||
List<JugadorConectado> get jugadores => _jugadores.values.toList();
|
||||
int get numJugadoresConectados => _jugadores.length;
|
||||
|
||||
/// Registra un listener de mensajes
|
||||
void onMensaje(OnMensajeCallback callback) {
|
||||
_listeners.add(callback);
|
||||
}
|
||||
|
||||
/// Elimina un listener
|
||||
void removeMensajeListener(OnMensajeCallback callback) {
|
||||
_listeners.remove(callback);
|
||||
}
|
||||
|
||||
void _notificarMensaje(String endpointId, MensajeP2P mensaje) {
|
||||
for (final listener in _listeners) {
|
||||
listener(endpointId, mensaje);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== HOST ====================
|
||||
|
||||
/// Inicia como host (anunciando el endpoint)
|
||||
Future<bool> iniciarHost(String nombreSala) async {
|
||||
// Nota: nearby_connections requiere permisos de ubicación y Bluetooth
|
||||
// que deben solicitarse antes de iniciar.
|
||||
// Implementación con el paquete nearby_connections:
|
||||
//
|
||||
// try {
|
||||
// await Nearby().startAdvertising(
|
||||
// nombreSala,
|
||||
// Strategy.P2P_STAR,
|
||||
// onConnectionInitiated: _onConexionIniciada,
|
||||
// onConnectionResult: _onResultadoConexion,
|
||||
// onDisconnected: _onDesconexion,
|
||||
// serviceId: 'es.freetimelab.farolero',
|
||||
// );
|
||||
// _esHost = true;
|
||||
// _endpointId = nombreSala;
|
||||
// notifyListeners();
|
||||
// return true;
|
||||
// } catch (e) {
|
||||
// debugPrint('Error iniciando host: $e');
|
||||
// return false;
|
||||
// }
|
||||
Future<bool> iniciarHost(String nombreSala, String miNombre) async {
|
||||
_nombreSala = nombreSala;
|
||||
_miNombre = miNombre;
|
||||
|
||||
_esHost = true;
|
||||
_endpointId = nombreSala;
|
||||
notifyListeners();
|
||||
return true;
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Error iniciando host: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Conecta a un host escaneado via QR
|
||||
Future<bool> conectarAHost(String endpointId, String nombre) async {
|
||||
// Implementación con el paquete nearby_connections:
|
||||
//
|
||||
// try {
|
||||
// await Nearby().startDiscovery(
|
||||
// nombre,
|
||||
// Strategy.P2P_STAR,
|
||||
// onEndpointFound: (id, name, serviceId) {
|
||||
// Nearby().requestConnection(nombre, id,
|
||||
// onConnectionInitiated: _onConexionIniciada,
|
||||
// onConnectionResult: _onResultadoConexion,
|
||||
// onDisconnected: _onDesconexion,
|
||||
// );
|
||||
// },
|
||||
// onEndpointLost: (id) {},
|
||||
// serviceId: 'es.freetimelab.farolero',
|
||||
// );
|
||||
// return true;
|
||||
// } catch (e) {
|
||||
// debugPrint('Error conectando: $e');
|
||||
// return false;
|
||||
// }
|
||||
// ==================== CLIENTE ====================
|
||||
|
||||
_conectado = true;
|
||||
notifyListeners();
|
||||
return true;
|
||||
/// Busca hosts disponibles
|
||||
Future<bool> buscarHosts(String miNombre) async {
|
||||
_miNombre = miNombre;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Conecta a un host específico
|
||||
Future<bool> conectarAHost(String endpointId, String miNombre) async {
|
||||
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('Conexión iniciada con $endpointId: ${info.endpointName}');
|
||||
// Auto-aceptar conexiones
|
||||
Nearby().acceptConnection(
|
||||
endpointId,
|
||||
onPayLoadRecieved: _onPayloadRecibido,
|
||||
onPayloadTransferUpdate: _onPayloadUpdate,
|
||||
);
|
||||
}
|
||||
|
||||
void _onResultadoConexion(String endpointId, Status status) {
|
||||
debugPrint('Resultado conexión $endpointId: $status');
|
||||
if (status == Status.CONNECTED) {
|
||||
if (_esHost) {
|
||||
// Host: esperar mensaje 'unirse' del cliente
|
||||
debugPrint('Cliente conectado: $endpointId');
|
||||
} else {
|
||||
// Cliente: conectado al host
|
||||
_hostEndpointId = endpointId;
|
||||
_conectado = true;
|
||||
// Enviar mensaje de unirse
|
||||
enviarMensaje(endpointId, MensajeP2P(
|
||||
tipo: TipoMensaje.unirse,
|
||||
datos: {'nombre': _miNombre ?? 'Jugador'},
|
||||
));
|
||||
}
|
||||
notifyListeners();
|
||||
} else {
|
||||
debugPrint('Conexión fallida con $endpointId');
|
||||
}
|
||||
}
|
||||
|
||||
void _onDesconexion(String endpointId) {
|
||||
debugPrint('Desconexión: $endpointId');
|
||||
if (_esHost) {
|
||||
final jugador = _jugadores.remove(endpointId);
|
||||
if (jugador != null) {
|
||||
// Notificar a todos que se desconectó
|
||||
enviarATodos(MensajeP2P(
|
||||
tipo: TipoMensaje.jugadorDesconectado,
|
||||
datos: {'nombre': jugador.nombre, 'endpointId': endpointId},
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Cliente perdió conexión con host
|
||||
_conectado = false;
|
||||
_hostEndpointId = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onEndpointEncontrado(String endpointId, String endpointName, String serviceId) {
|
||||
debugPrint('Host encontrado: $endpointName ($endpointId)');
|
||||
// Auto-conectar al primer host encontrado
|
||||
conectarAHost(endpointId, _miNombre ?? 'Jugador');
|
||||
}
|
||||
|
||||
void _onEndpointPerdido(String? endpointId) {
|
||||
debugPrint('Endpoint perdido: $endpointId');
|
||||
}
|
||||
|
||||
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) {
|
||||
// No necesitamos trackear progreso para bytes pequeños
|
||||
}
|
||||
|
||||
// ==================== 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) {
|
||||
switch (mensaje.tipo) {
|
||||
case TipoMensaje.unirse:
|
||||
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador';
|
||||
_jugadores[endpointId] = JugadorConectado(
|
||||
endpointId: endpointId,
|
||||
nombre: nombre,
|
||||
);
|
||||
// Enviar info de sala al nuevo jugador
|
||||
enviarMensaje(endpointId, MensajeP2P(
|
||||
tipo: TipoMensaje.salaInfo,
|
||||
datos: {
|
||||
'sala': _nombreSala,
|
||||
'jugadores': _jugadores.values.map((j) => {
|
||||
'nombre': j.nombre,
|
||||
'endpointId': j.endpointId,
|
||||
}).toList(),
|
||||
},
|
||||
));
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.voto:
|
||||
// Propagar al flujo de juego
|
||||
_notificarMensaje(endpointId, mensaje);
|
||||
break;
|
||||
|
||||
case TipoMensaje.listo:
|
||||
final jugador = _jugadores[endpointId];
|
||||
if (jugador != null) {
|
||||
jugador.listo = true;
|
||||
notifyListeners();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _procesarMensajeCliente(String endpointId, MensajeP2P mensaje) {
|
||||
switch (mensaje.tipo) {
|
||||
case TipoMensaje.salaInfo:
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.partidaInicio:
|
||||
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
||||
_soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.fase:
|
||||
_faseActual = mensaje.datos['fase'] as String?;
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.votacionResultado:
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.partidaFin:
|
||||
_datosPartida = mensaje.datos;
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
case TipoMensaje.jugadorDesconectado:
|
||||
notifyListeners();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ENVÍO ====================
|
||||
|
||||
/// Envía un mensaje a un dispositivo específico
|
||||
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
|
||||
// Implementación:
|
||||
// final bytes = Uint8List.fromList(utf8.encode(mensaje.toJson()));
|
||||
// await Nearby().sendBytesPayload(endpointId, bytes);
|
||||
debugPrint('Enviar a $endpointId: ${mensaje.toJson()}');
|
||||
try {
|
||||
await Nearby().sendBytesPayload(endpointId, mensaje.toBytes());
|
||||
} catch (e) {
|
||||
debugPrint('Error enviando a $endpointId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Envía un mensaje a todos los dispositivos conectados
|
||||
/// Envía un mensaje a todos los dispositivos conectados (solo host)
|
||||
Future<void> enviarATodos(MensajeP2P mensaje) async {
|
||||
for (final id in _dispositivos) {
|
||||
for (final id in _jugadores.keys) {
|
||||
await enviarMensaje(id, mensaje);
|
||||
}
|
||||
}
|
||||
|
||||
/// Desconecta y limpia
|
||||
// ==================== HOST: ACCIONES DE JUEGO ====================
|
||||
|
||||
/// Host envía inicio de partida con la palabra de cada jugador
|
||||
Future<void> enviarInicioPartida({
|
||||
required String palabraSecreta,
|
||||
required String categoria,
|
||||
required Map<String, bool> impostores, // endpointId -> esImpostor
|
||||
}) async {
|
||||
for (final entry in _jugadores.entries) {
|
||||
final esImpostor = impostores[entry.key] ?? false;
|
||||
await enviarMensaje(entry.key, MensajeP2P(
|
||||
tipo: TipoMensaje.partidaInicio,
|
||||
datos: {
|
||||
'palabra': esImpostor ? null : palabraSecreta,
|
||||
'esImpostor': esImpostor,
|
||||
'categoria': categoria,
|
||||
'numJugadores': _jugadores.length + 1, // +1 por el host
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Host envía cambio de fase
|
||||
Future<void> enviarCambioFase(String fase, [Map<String, dynamic>? extra]) async {
|
||||
final datos = {'fase': fase, ...?extra};
|
||||
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
|
||||
}
|
||||
|
||||
/// Host envía resultado de votación
|
||||
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
|
||||
await enviarATodos(MensajeP2P(
|
||||
tipo: TipoMensaje.votacionResultado,
|
||||
datos: resultado,
|
||||
));
|
||||
}
|
||||
|
||||
/// Host envía fin de partida
|
||||
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
|
||||
await enviarATodos(MensajeP2P(
|
||||
tipo: TipoMensaje.partidaFin,
|
||||
datos: resultado,
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== LIMPIEZA ====================
|
||||
|
||||
/// Desconecta y limpia todo
|
||||
Future<void> desconectar() async {
|
||||
// await Nearby().stopAllEndpoints();
|
||||
// await Nearby().stopAdvertising();
|
||||
// await Nearby().stopDiscovery();
|
||||
try {
|
||||
await Nearby().stopAllEndpoints();
|
||||
if (_anunciando) await Nearby().stopAdvertising();
|
||||
if (_buscando) await Nearby().stopDiscovery();
|
||||
} catch (e) {
|
||||
debugPrint('Error desconectando: $e');
|
||||
}
|
||||
|
||||
_esHost = false;
|
||||
_conectado = false;
|
||||
_endpointId = null;
|
||||
_dispositivos.clear();
|
||||
_buscando = false;
|
||||
_anunciando = false;
|
||||
_miEndpointId = null;
|
||||
_hostEndpointId = null;
|
||||
_nombreSala = null;
|
||||
_miNombre = null;
|
||||
_palabraRecibida = null;
|
||||
_soyImpostor = null;
|
||||
_faseActual = null;
|
||||
_datosPartida = null;
|
||||
_jugadores.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -140,8 +450,25 @@ class ServicioNearby extends ChangeNotifier {
|
||||
String generarDatosQR(String nombreSala) {
|
||||
return json.encode({
|
||||
'app': 'farolero',
|
||||
'endpoint': _endpointId,
|
||||
'sala': nombreSala,
|
||||
'host': _miNombre,
|
||||
});
|
||||
}
|
||||
|
||||
/// Parsea datos de QR escaneado
|
||||
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() {
|
||||
desconectar();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user