feat: STOMP WebSocket client implementation and UI Guardian Dashboard
This commit is contained in:
parent
6eaffaa234
commit
086d60cb7b
@ -1,41 +1,193 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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 '../constants/app_constants.dart';
|
||||||
import '../storage/secure_storage.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 {
|
class WebSocketService {
|
||||||
final SecureStorage _storage;
|
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);
|
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();
|
await disconnect();
|
||||||
|
|
||||||
final token = await _storage.getAccessToken();
|
final token = await _storage.getAccessToken();
|
||||||
final wsUrl = Uri.parse('${AppConstants.buildWsUrl(serverUrl)}${token == null ? '' : '?token=$token'}');
|
final wsUrl = AppConstants.buildWsUrl(serverUrl);
|
||||||
try {
|
|
||||||
_channel = WebSocketChannel.connect(wsUrl);
|
final completer = Completer<void>();
|
||||||
_subscription = _channel!.stream.listen(
|
|
||||||
onMessage ?? (event) => debugPrint('$event'),
|
_client = StompClient(
|
||||||
onError: (Object error, StackTrace stackTrace) => debugPrint('$error'),
|
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 {
|
||||||
|
await completer.future.timeout(const Duration(seconds: 5));
|
||||||
} catch (e) {
|
} 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) {
|
/// Subscribe ke live GPS updates dari User.
|
||||||
_channel?.sink.add(message);
|
/// 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 {
|
Future<void> disconnect() async {
|
||||||
await _subscription?.cancel();
|
_locationUnsub?.call();
|
||||||
_subscription = null;
|
_sosUnsub?.call();
|
||||||
await _channel?.sink.close();
|
_notifUnsub?.call();
|
||||||
_channel = null;
|
_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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -947,10 +947,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mgrs_dart:
|
mgrs_dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1532,6 +1532,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.1"
|
||||||
|
stomp_dart_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: stomp_dart_client
|
||||||
|
sha256: "9ca00600a212f1e08fda614cf6815437829b1d08d8911ff5c798f130a2fa2d59"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1584,26 +1592,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.26.2"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.11"
|
version: "0.6.12"
|
||||||
tflite_flutter:
|
tflite_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -76,6 +76,9 @@ dependencies:
|
|||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
shimmer: ^3.0.0
|
shimmer: ^3.0.0
|
||||||
|
|
||||||
|
# STOMP client untuk WebSocket
|
||||||
|
stomp_dart_client: ^2.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user