import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:nearby_connections/nearby_connections.dart'; /// Tipos de mensajes en el protocolo P2P enum TipoMensaje { salaInfo, partidaInicio, fase, votacionResultado, partidaFin, unirse, voto, listo, ping, jugadorDesconectado, } /// Mensaje del protocolo P2P entre dispositivos class MensajeP2P { final TipoMensaje tipo; final Map 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; return MensajeP2P( tipo: TipoMensaje.values.firstWhere((t) => t.name == mapa['tipo']), datos: mapa['datos'] as Map, ); } 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. class ServicioNearby extends ChangeNotifier { static const _serviceId = 'es.freetimelab.farolero'; bool _esHost = false; bool _conectado = false; bool _buscando = false; bool _anunciando = false; String? _miEndpointId; String? _hostEndpointId; String? _nombreSala; String? _miNombre; final Map _jugadores = {}; final List _listeners = []; // Hosts descubiertos (para discovery automático) final Map _hostsEncontrados = {}; // endpointId -> nombre // Estado para clientes String? _palabraRecibida; bool? _soyImpostor; String? _faseActual; Map? _datosPartida; bool get esHost => _esHost; bool get conectado => _conectado; 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? get datosPartida => _datosPartida; List get jugadores => _jugadores.values.toList(); int get numJugadoresConectados => _jugadores.length; Map get hostsEncontrados => Map.unmodifiable(_hostsEncontrados); /// 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 iniciarHost(String nombreSala, String miNombre) async { _nombreSala = nombreSala; _miNombre = miNombre; 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; } } // ==================== CLIENTE ==================== /// Busca hosts disponibles Future 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 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)'); _hostsEncontrados[endpointId] = endpointName; notifyListeners(); } void _onEndpointPerdido(String? endpointId) { debugPrint('Endpoint perdido: $endpointId'); if (endpointId != null) { _hostsEncontrados.remove(endpointId); notifyListeners(); } } /// Para el discovery sin desconectar Future 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) { // 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 enviarMensaje(String endpointId, MensajeP2P mensaje) async { try { await Nearby().sendBytesPayload(endpointId, mensaje.toBytes()); } catch (e) { debugPrint('Error enviando a $endpointId: $e'); } } /// Envía un mensaje a todos los dispositivos conectados (solo host) Future enviarATodos(MensajeP2P mensaje) async { for (final id in _jugadores.keys) { await enviarMensaje(id, mensaje); } } // ==================== HOST: ACCIONES DE JUEGO ==================== /// Host envía inicio de partida con la palabra de cada jugador Future enviarInicioPartida({ required String palabraSecreta, required String categoria, required Map 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 enviarCambioFase(String fase, [Map? extra]) async { final datos = {'fase': fase, ...?extra}; await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos)); } /// Host envía resultado de votación Future enviarResultadoVotacion(Map resultado) async { await enviarATodos(MensajeP2P( tipo: TipoMensaje.votacionResultado, datos: resultado, )); } /// Host envía fin de partida Future enviarFinPartida(Map resultado) async { await enviarATodos(MensajeP2P( tipo: TipoMensaje.partidaFin, datos: resultado, )); } // ==================== LIMPIEZA ==================== /// Desconecta y limpia todo Future desconectar() async { 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; _nombreSala = null; _miNombre = null; _palabraRecibida = null; _soyImpostor = null; _faseActual = null; _datosPartida = null; _jugadores.clear(); _hostsEncontrados.clear(); notifyListeners(); } /// Genera los datos para el código QR de conexión String generarDatosQR(String nombreSala) { return json.encode({ 'app': 'farolero', 'sala': nombreSala, 'host': _miNombre, }); } /// Parsea datos de QR escaneado static Map? parsearQR(String datos) { try { final mapa = json.decode(datos) as Map; if (mapa['app'] == 'farolero') return mapa; return null; } catch (_) { return null; } } @override void dispose() { desconectar(); super.dispose(); } }