feat: STOMP WebSocket client implementation and UI Guardian Dashboard

This commit is contained in:
2026-05-15 19:49:45 +07:00
parent 6eaffaa234
commit 086d60cb7b
4 changed files with 1782 additions and 394 deletions
@@ -1,41 +1,193 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:stomp_dart_client/stomp_dart_client.dart';
import '../constants/app_constants.dart';
import '../storage/secure_storage.dart';
/// WebSocket Service pakai STOMP (stomp_dart_client).
///
/// Spring Boot backend pakai @EnableWebSocketMessageBroker dengan SockJS.
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
///
/// Subscriptions yang dipakai:
/// Guardian → /topic/location/{userId} live GPS update
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
/// User → /queue/notif/{userId} notifikasi dari Guardian
class WebSocketService {
final SecureStorage _storage;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
StompClient? _client;
bool _connected = false;
// Subscription callbacks
void Function(double lat, double lng)? _onLocation;
void Function(Map<String, dynamic> sosData)? _onSos;
void Function(Map<String, dynamic> notifData)? _onNotif;
// Subscription frames (untuk unsubscribe)
StompUnsubscribe? _locationUnsub;
StompUnsubscribe? _sosUnsub;
StompUnsubscribe? _notifUnsub;
WebSocketService(this._storage);
Future<void> connect(String serverUrl, {void Function(dynamic event)? onMessage}) async {
bool get isConnected => _connected;
/// Connect ke WebSocket server.
/// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices).
Future<void> connect(String serverUrl) async {
await disconnect();
final token = await _storage.getAccessToken();
final wsUrl = Uri.parse('${AppConstants.buildWsUrl(serverUrl)}${token == null ? '' : '?token=$token'}');
final wsUrl = AppConstants.buildWsUrl(serverUrl);
final completer = Completer<void>();
_client = StompClient(
config: StompConfig(
url: wsUrl,
onConnect: (frame) {
_connected = true;
debugPrint('[WS] STOMP connected to $wsUrl');
if (!completer.isCompleted) completer.complete();
},
onDisconnect: (frame) {
_connected = false;
debugPrint('[WS] STOMP disconnected');
},
onStompError: (frame) {
debugPrint('[WS] STOMP error: ${frame.body}');
if (!completer.isCompleted) {
completer.completeError(frame.body ?? 'STOMP error');
}
},
onWebSocketError: (dynamic error) {
debugPrint('[WS] WebSocket error: $error');
_connected = false;
if (!completer.isCompleted) completer.completeError(error);
},
onUnhandledMessage: (frame) {
debugPrint('[WS] Unhandled: ${frame.body}');
},
// Inject JWT token di header STOMP CONNECT
stompConnectHeaders:
token != null ? {'Authorization': 'Bearer $token'} : {},
webSocketConnectHeaders:
token != null ? {'Authorization': 'Bearer $token'} : {},
reconnectDelay: const Duration(seconds: 5),
),
);
_client!.activate();
// Tunggu connected atau timeout
try {
_channel = WebSocketChannel.connect(wsUrl);
_subscription = _channel!.stream.listen(
onMessage ?? (event) => debugPrint('$event'),
onError: (Object error, StackTrace stackTrace) => debugPrint('$error'),
);
await completer.future.timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('WebSocket connect skipped: $e');
debugPrint('[WS] Connect timeout/error: $e');
// Don't throw — let dashboard work without WS
}
}
void send(Object message) {
_channel?.sink.add(message);
/// Subscribe ke live GPS updates dari User.
/// Guardian panggil ini setelah connect.
/// [userId] = ID dari ROLE_USER yang dipair.
void subscribeLocation(String userId,
void Function(double lat, double lng) callback) {
_onLocation = callback;
if (_client == null || !_connected) {
debugPrint('[WS] subscribeLocation skipped — not connected');
return;
}
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
_locationUnsub = _client!.subscribe(
destination: '/topic/location/$userId',
callback: (frame) {
try {
final data =
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
final lat = (data['lat'] as num?)?.toDouble();
final lng = (data['lng'] as num?)?.toDouble();
if (lat != null && lng != null) {
_onLocation?.call(lat, lng);
}
} catch (e) {
debugPrint('[WS] Location parse error: $e');
}
},
);
debugPrint('[WS] Subscribed to /topic/location/$userId');
}
/// Subscribe ke SOS alert untuk Guardian.
/// [guardianId] = ID dari ROLE_GUARDIAN yang login.
void subscribeSos(void Function(Map<String, dynamic> sosData) callback) {
_onSos = callback;
if (_client == null || !_connected) return;
_storage.getUserId().then((guardianId) {
if (guardianId == null) return;
_sosUnsub?.call();
_sosUnsub = _client!.subscribe(
destination: '/queue/sos/$guardianId',
callback: (frame) {
try {
final data =
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
_onSos?.call(data);
} catch (e) {
debugPrint('[WS] SOS parse error: $e');
}
},
);
debugPrint('[WS] Subscribed to /queue/sos/$guardianId');
});
}
/// Subscribe ke notifikasi Guardian → User.
/// [userId] = ID dari ROLE_USER yang login.
void subscribeNotification(
void Function(Map<String, dynamic> notifData) callback) {
_onNotif = callback;
if (_client == null || !_connected) return;
_storage.getUserId().then((userId) {
if (userId == null) return;
_notifUnsub?.call();
_notifUnsub = _client!.subscribe(
destination: '/queue/notif/$userId',
callback: (frame) {
try {
final data =
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
_onNotif?.call(data);
} catch (e) {
debugPrint('[WS] Notif parse error: $e');
}
},
);
debugPrint('[WS] Subscribed to /queue/notif/$userId');
});
}
/// Disconnect dan cleanup semua subscriptions.
Future<void> disconnect() async {
await _subscription?.cancel();
_subscription = null;
await _channel?.sink.close();
_channel = null;
_locationUnsub?.call();
_sosUnsub?.call();
_notifUnsub?.call();
_locationUnsub = null;
_sosUnsub = null;
_notifUnsub = null;
_client?.deactivate();
_client = null;
_connected = false;
}
// Legacy compat — lama pakai onMessage raw
void send(Object message) {
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
}
}