Update UI + Agora Call
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import '../network/api_client.dart';
|
||||
@@ -7,9 +10,19 @@ import '../network/api_client.dart';
|
||||
class CallService {
|
||||
final ApiClient _apiClient;
|
||||
RtcEngine? _engine;
|
||||
VoidCallback? _onRemoteUserJoined;
|
||||
VoidCallback? _onRemoteUserOffline;
|
||||
|
||||
CallService(this._apiClient);
|
||||
|
||||
void setRemoteUserJoinedCallback(VoidCallback? callback) {
|
||||
_onRemoteUserJoined = callback;
|
||||
}
|
||||
|
||||
void setRemoteUserOfflineCallback(VoidCallback? callback) {
|
||||
_onRemoteUserOffline = callback;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
|
||||
final res = await _apiClient.dio.post(
|
||||
'/shared/call/token',
|
||||
@@ -41,29 +54,83 @@ class CallService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> callPairedUser({int uid = 0}) async {
|
||||
Future<Map<String, dynamic>?> startPairedCall({int uid = 0}) async {
|
||||
final receiverId = await getPairedReceiverId();
|
||||
if (receiverId == null) return false;
|
||||
if (receiverId == null) return null;
|
||||
|
||||
final tokenData = await requestToken(receiverId: receiverId);
|
||||
final channelName = tokenData?['channelName']?.toString();
|
||||
final token = tokenData?['token']?.toString();
|
||||
if (channelName == null || channelName.isEmpty) return false;
|
||||
final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid;
|
||||
if (channelName == null || channelName.isEmpty) return null;
|
||||
|
||||
final joined = await joinChannel(
|
||||
channelName: channelName,
|
||||
token: token,
|
||||
uid: uid,
|
||||
uid: localUid,
|
||||
);
|
||||
if (joined) {
|
||||
await notifyIncomingCall(
|
||||
receiverId: receiverId,
|
||||
channelName: channelName,
|
||||
agoraToken: token,
|
||||
receiverUid: uid,
|
||||
);
|
||||
}
|
||||
return joined;
|
||||
if (!joined) return null;
|
||||
|
||||
await notifyIncomingCall(
|
||||
receiverId: receiverId,
|
||||
channelName: channelName,
|
||||
agoraToken: token,
|
||||
receiverUid: 0,
|
||||
);
|
||||
|
||||
return {
|
||||
'receiverId': receiverId,
|
||||
'channelName': channelName,
|
||||
'token': token,
|
||||
'uid': localUid,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> callPairedUser({int uid = 0}) async {
|
||||
return await startPairedCall(uid: uid) != null;
|
||||
}
|
||||
|
||||
Future<void> acceptIncomingCall({
|
||||
required int callerId,
|
||||
required String channelName,
|
||||
}) async {
|
||||
await _apiClient.dio.post('/shared/call/accept', data: {
|
||||
'callerId': callerId.toString(),
|
||||
'channelName': channelName,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getAcceptedCall() async {
|
||||
final res = await _apiClient.dio.get('/shared/call/accepted');
|
||||
final data = res.data['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getCallState(String? channelName) async {
|
||||
if (channelName == null || channelName.isEmpty) return null;
|
||||
final res = await _apiClient.dio.get(
|
||||
'/shared/call/state',
|
||||
queryParameters: {'channelName': channelName},
|
||||
);
|
||||
final data = res.data['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<void> clearAcceptedCall() async {
|
||||
await _apiClient.dio.delete('/shared/call/accepted');
|
||||
}
|
||||
|
||||
Future<void> clearPendingCall() async {
|
||||
await _apiClient.dio.delete('/shared/call/pending');
|
||||
}
|
||||
|
||||
Future<void> endCall(int? otherId, {String? channelName}) async {
|
||||
if (otherId == null) return;
|
||||
await _apiClient.dio.post('/shared/call/end', data: {
|
||||
'otherId': otherId.toString(),
|
||||
if (channelName != null && channelName.isNotEmpty)
|
||||
'channelName': channelName,
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> joinChannel({
|
||||
@@ -71,32 +138,94 @@ class CallService {
|
||||
String? token,
|
||||
int uid = 0,
|
||||
}) async {
|
||||
final joinCompleter = Completer<bool>();
|
||||
try {
|
||||
if (AppConstants.agoraAppId.isEmpty) {
|
||||
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
||||
return false;
|
||||
}
|
||||
if (!await _ensureMicrophonePermission()) {
|
||||
debugPrint('Agora join skipped: microphone permission denied');
|
||||
return false;
|
||||
}
|
||||
|
||||
_engine ??= createAgoraRtcEngine();
|
||||
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
|
||||
await _engine!.initialize(
|
||||
const RtcEngineContext(appId: AppConstants.agoraAppId),
|
||||
);
|
||||
_engine!.registerEventHandler(
|
||||
RtcEngineEventHandler(
|
||||
onJoinChannelSuccess: (_, __) {
|
||||
if (!joinCompleter.isCompleted) joinCompleter.complete(true);
|
||||
},
|
||||
onUserJoined: (_, remoteUid, __) {
|
||||
debugPrint('Agora remote user joined: $remoteUid');
|
||||
_onRemoteUserJoined?.call();
|
||||
},
|
||||
onUserOffline: (_, remoteUid, reason) {
|
||||
debugPrint('Agora remote user offline: $remoteUid $reason');
|
||||
_onRemoteUserOffline?.call();
|
||||
},
|
||||
onError: (type, msg) {
|
||||
debugPrint('Agora error: $type $msg');
|
||||
if (!joinCompleter.isCompleted) joinCompleter.complete(false);
|
||||
},
|
||||
),
|
||||
);
|
||||
await _engine!.setChannelProfile(
|
||||
ChannelProfileType.channelProfileCommunication,
|
||||
);
|
||||
await _engine!.enableAudio();
|
||||
await _engine!.enableLocalAudio(true);
|
||||
await _engine!.muteLocalAudioStream(false);
|
||||
await _engine!.setEnableSpeakerphone(true);
|
||||
await _engine!.joinChannel(
|
||||
token: token ?? '',
|
||||
channelId: channelName,
|
||||
uid: uid,
|
||||
options: const ChannelMediaOptions(),
|
||||
options: const ChannelMediaOptions(
|
||||
channelProfile: ChannelProfileType.channelProfileCommunication,
|
||||
clientRoleType: ClientRoleType.clientRoleBroadcaster,
|
||||
publishMicrophoneTrack: true,
|
||||
autoSubscribeAudio: true,
|
||||
),
|
||||
);
|
||||
return joinCompleter.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
debugPrint('Agora join timeout for channel $channelName');
|
||||
return false;
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Agora join skipped: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureMicrophonePermission() async {
|
||||
if (kIsWeb) return true;
|
||||
final status = await Permission.microphone.request();
|
||||
return status.isGranted || status.isLimited;
|
||||
}
|
||||
|
||||
Future<void> setMuted(bool muted) async {
|
||||
await _engine?.muteLocalAudioStream(muted);
|
||||
}
|
||||
|
||||
Future<void> setSpeakerEnabled(bool enabled) async {
|
||||
await _engine?.setEnableSpeakerphone(enabled);
|
||||
}
|
||||
|
||||
Future<void> leave() async {
|
||||
_onRemoteUserJoined = null;
|
||||
_onRemoteUserOffline = null;
|
||||
await _engine?.leaveChannel();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_onRemoteUserJoined = null;
|
||||
_onRemoteUserOffline = null;
|
||||
await _engine?.release();
|
||||
_engine = null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../../app/router.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
class FcmService {
|
||||
final ApiClient _apiClient;
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
FcmService(this._apiClient);
|
||||
|
||||
@@ -18,6 +22,14 @@ class FcmService {
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
final payload = response.payload;
|
||||
if (payload == null || payload.isEmpty) return;
|
||||
try {
|
||||
final data = Map<String, dynamic>.from(jsonDecode(payload) as Map);
|
||||
_handlePayloadNavigation(data);
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
await _messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
final token = await _messaging.getToken();
|
||||
@@ -26,7 +38,16 @@ class FcmService {
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
debugPrint('FCM foreground: ${message.data}');
|
||||
_showLocalNotification(message);
|
||||
_handlePayloadNavigation(message.data);
|
||||
});
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
||||
_handlePayloadNavigation(message.data);
|
||||
});
|
||||
final initialMessage =
|
||||
await FirebaseMessaging.instance.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
_handlePayloadNavigation(initialMessage.data);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('FCM init skipped: $e');
|
||||
}
|
||||
@@ -42,8 +63,11 @@ class FcmService {
|
||||
|
||||
Future<void> _showLocalNotification(RemoteMessage message) async {
|
||||
final notification = message.notification;
|
||||
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru';
|
||||
final title =
|
||||
notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||
final body = notification?.body ??
|
||||
message.data['body']?.toString() ??
|
||||
'Ada update baru';
|
||||
await _localNotifications.show(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
@@ -57,7 +81,26 @@ class FcmService {
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
payload: message.data['type']?.toString(),
|
||||
payload: jsonEncode(message.data),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePayloadNavigation(Map<String, dynamic> data) {
|
||||
final type = data['type']?.toString();
|
||||
if (type == 'INCOMING_CALL') {
|
||||
appRouter.go('/incoming-call', extra: data);
|
||||
return;
|
||||
}
|
||||
if (type == 'SOS_ALERT') {
|
||||
appRouter.go('/guardian/dashboard');
|
||||
return;
|
||||
}
|
||||
if (type == 'PAIRING_INVITE' || type == 'PAIRING_RESPONSE') {
|
||||
appRouter.go('/user/pairing');
|
||||
return;
|
||||
}
|
||||
if (type == 'NOTIFICATION') {
|
||||
appRouter.go('/user/notifications');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../app/router.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
class IncomingCallPollingService {
|
||||
IncomingCallPollingService(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
Timer? _timer;
|
||||
String? _lastChannel;
|
||||
|
||||
void start() {
|
||||
if (_timer != null) return;
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _check());
|
||||
unawaited(_check());
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_lastChannel = null;
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
try {
|
||||
final res = await _apiClient.dio
|
||||
.get('/shared/call/pending')
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final data = res.data['data'];
|
||||
if (data is! Map) return;
|
||||
if (data['type']?.toString() != 'INCOMING_CALL') return;
|
||||
|
||||
final channel = data['channelName']?.toString();
|
||||
if (channel == null || channel.isEmpty || channel == _lastChannel) return;
|
||||
_lastChannel = channel;
|
||||
|
||||
appRouter.go('/incoming-call', extra: Map<String, dynamic>.from(data));
|
||||
} catch (e) {
|
||||
debugPrint('Incoming call polling skipped: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import '../storage/secure_storage.dart';
|
||||
/// 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
|
||||
/// 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;
|
||||
|
||||
@@ -26,11 +26,13 @@ class WebSocketService {
|
||||
void Function(double lat, double lng)? _onLocation;
|
||||
void Function(Map<String, dynamic> sosData)? _onSos;
|
||||
void Function(Map<String, dynamic> notifData)? _onNotif;
|
||||
void Function(Map<String, dynamic> callData)? _onCall;
|
||||
|
||||
// Subscription frames (untuk unsubscribe)
|
||||
StompUnsubscribe? _locationUnsub;
|
||||
StompUnsubscribe? _sosUnsub;
|
||||
StompUnsubscribe? _notifUnsub;
|
||||
StompUnsubscribe? _callUnsub;
|
||||
|
||||
WebSocketService(this._storage);
|
||||
|
||||
@@ -88,18 +90,18 @@ class WebSocketService {
|
||||
await completer.future.timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Connect timeout/error: $e');
|
||||
// Don't throw — let dashboard work without WS
|
||||
// Don't throw — let dashboard work without WS
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
void subscribeLocation(
|
||||
String userId, void Function(double lat, double lng) callback) {
|
||||
_onLocation = callback;
|
||||
if (_client == null || !_connected) {
|
||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||
return;
|
||||
}
|
||||
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
|
||||
@@ -107,8 +109,7 @@ class WebSocketService {
|
||||
destination: '/topic/location/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
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) {
|
||||
@@ -135,8 +136,7 @@ class WebSocketService {
|
||||
destination: '/queue/sos/$guardianId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onSos?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] SOS parse error: $e');
|
||||
@@ -147,7 +147,7 @@ class WebSocketService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Subscribe ke notifikasi Guardian → User.
|
||||
/// Subscribe ke notifikasi Guardian → User.
|
||||
/// [userId] = ID dari ROLE_USER yang login.
|
||||
void subscribeNotification(
|
||||
void Function(Map<String, dynamic> notifData) callback) {
|
||||
@@ -161,8 +161,7 @@ class WebSocketService {
|
||||
destination: '/queue/notif/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onNotif?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Notif parse error: $e');
|
||||
@@ -173,20 +172,46 @@ class WebSocketService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Subscribe ke panggilan masuk realtime. Ini melengkapi FCM agar call tetap
|
||||
/// masuk saat app foreground atau ketika FCM di app clone tidak stabil.
|
||||
void subscribeCall(void Function(Map<String, dynamic> callData) callback) {
|
||||
_onCall = callback;
|
||||
if (_client == null || !_connected) return;
|
||||
|
||||
_storage.getUserId().then((userId) {
|
||||
if (userId == null) return;
|
||||
_callUnsub?.call();
|
||||
_callUnsub = _client!.subscribe(
|
||||
destination: '/queue/call/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onCall?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Call parse error: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
debugPrint('[WS] Subscribed to /queue/call/$userId');
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect dan cleanup semua subscriptions.
|
||||
Future<void> disconnect() async {
|
||||
_locationUnsub?.call();
|
||||
_sosUnsub?.call();
|
||||
_notifUnsub?.call();
|
||||
_callUnsub?.call();
|
||||
_locationUnsub = null;
|
||||
_sosUnsub = null;
|
||||
_notifUnsub = null;
|
||||
_callUnsub = null;
|
||||
_client?.deactivate();
|
||||
_client = null;
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
// Legacy compat — lama pakai onMessage raw
|
||||
// Legacy compat — lama pakai onMessage raw
|
||||
void send(Object message) {
|
||||
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user