import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:web_socket_channel/web_socket_channel.dart'; import '../core/cache_manager.dart'; import '../core/constants/api_url.dart'; class WebSocketService extends ChangeNotifier { static final WebSocketService _instance = WebSocketService._internal(); factory WebSocketService() => _instance; WebSocketService._internal(); WebSocketChannel? _channel; Timer? _reconnectTimer; bool _isConnected = false; bool _disposed = false; String? _socketId; final _eventController = StreamController>.broadcast(); Stream> get eventStream => _eventController.stream; bool get isConnected => _isConnected; Future connect() async { if (_isConnected || _disposed) return; try { final baseUrl = ApiUrl.baseUrl; final token = CacheManager.authBox.get('auth_token') ?? ''; final wsUrl = _buildWsUrl(baseUrl); if (wsUrl == null) return; final uri = Uri.parse('$wsUrl/app/mpg-hris-key'); _channel = WebSocketChannel.connect(uri); await _channel!.ready; _isConnected = true; _reconnectTimer?.cancel(); notifyListeners(); debugPrint('[WS] Terhubung ke $uri'); _channel!.stream.listen( (message) => _handleMessage(message, token), onDone: () { debugPrint('[WS] Koneksi terputus'); _isConnected = false; _socketId = null; notifyListeners(); _scheduleReconnect(); }, onError: (error) { debugPrint('[WS] Error: $error'); _isConnected = false; _socketId = null; notifyListeners(); _scheduleReconnect(); }, ); } catch (e) { debugPrint('[WS] Gagal connect: $e'); _isConnected = false; _scheduleReconnect(); } } String? _buildWsUrl(String apiBaseUrl) { try { final uri = Uri.parse(apiBaseUrl); final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; // Tunnel (ngrok / localtunnel): WebSocket langsung via tunnel if (uri.host.contains('ngrok-free.dev') || uri.host.contains('loca.lt')) { return 'wss://icy-bugs-hear.loca.lt'; } if (uri.host.contains('trycloudflare.com')) { return '$scheme://${uri.host}'; } // Hosting: Reverb lewat Nginx proxy /app if (uri.host.contains('hris-mp.web.id')) { return 'wss://hris-mp.web.id'; } // Lokal: Reverb langsung di port 8080 return '$scheme://${uri.host}:8080'; } catch (e) { debugPrint('[WS] URL invalid: $e'); return null; } } Future _subscribeToChannels() async { final userId = CacheManager.authBox.get('user_id'); if (userId == null) return; await _sendSubscribe('private-user.$userId'); _sendSubscribePublic('pengumuman'); } Future _sendSubscribe(String channel) async { if (_socketId == null) { debugPrint('[WS] Tidak bisa subscribe $channel: socketId null'); return; } final token = CacheManager.authBox.get('auth_token') ?? ''; final authSignature = await _getChannelAuth(channel, token); if (authSignature == null) { debugPrint('[WS] Auth gagal untuk channel: $channel'); return; } final data = { 'event': 'pusher:subscribe', 'data': { 'channel': channel, 'auth': authSignature, }, }; _channel?.sink.add(jsonEncode(data)); debugPrint('[WS] Subscribe (private): $channel'); } void _sendSubscribePublic(String channel) { final data = { 'event': 'pusher:subscribe', 'data': { 'channel': channel, }, }; _channel?.sink.add(jsonEncode(data)); debugPrint('[WS] Subscribe (public): $channel'); } Future _getChannelAuth(String channel, String token) async { try { final baseUrl = ApiUrl.baseUrl; final authUrl = '$baseUrl/broadcasting/auth'; final response = await http.post( Uri.parse(authUrl), headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, body: { 'socket_id': _socketId!, 'channel_name': channel, }, ); if (response.statusCode == 200) { final json = jsonDecode(response.body); final auth = json['auth'] as String?; debugPrint('[WS] Auth berhasil untuk $channel'); return auth; } else { debugPrint('[WS] Auth error ${response.statusCode}: ${response.body}'); return null; } } catch (e) { debugPrint('[WS] Auth exception: $e'); return null; } } void _handleMessage(dynamic rawMessage, String token) { try { final message = jsonDecode(rawMessage as String) as Map; final event = message['event'] as String?; if (event == 'pusher:connection_established') { final connData = jsonDecode(message['data'] as String); _socketId = connData['socket_id'] as String?; debugPrint('[WS] Connection established, socketId=$_socketId'); _subscribeToChannels(); return; } if (event == 'pusher_internal:subscription_succeeded') { debugPrint('[WS] Subscription berhasil: ${message['channel']}'); return; } if (event == 'pusher:error') { debugPrint('[WS] Pusher error: ${message['data']}'); return; } if (event != null && !event.startsWith('pusher')) { final dataRaw = message['data']; final data = dataRaw is String ? jsonDecode(dataRaw) : dataRaw; _eventController.add({ 'event': event, 'channel': message['channel'], 'data': data, }); debugPrint('[WS] Event diterima: $event'); } } catch (e) { debugPrint('[WS] Parse error: $e'); } } void _scheduleReconnect() { _reconnectTimer?.cancel(); if (_disposed) return; _reconnectTimer = Timer(const Duration(seconds: 5), () { debugPrint('[WS] Reconnecting...'); connect(); }); } void disconnect() { _reconnectTimer?.cancel(); _channel?.sink.close(); _channel = null; _isConnected = false; _socketId = null; notifyListeners(); debugPrint('[WS] Disconnected'); } @override void dispose() { _disposed = true; disconnect(); _eventController.close(); super.dispose(); } }