Update UI + Agora Call

This commit is contained in:
2026-05-27 19:36:01 +07:00
parent a629357e8c
commit 3cb32a4d69
42 changed files with 6516 additions and 1285 deletions
@@ -61,7 +61,7 @@ class AppConstants {
await prefs.setString(_selectedYoloModelKey, path);
}
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
static const String agoraAppId =
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
// Agora App ID tetap bisa dioverride saat build: --dart-define=AGORA_APP_ID=...
static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID',
defaultValue: 'e36c2b6592e34cfda1f6ea6432a5e68d');
}
@@ -71,6 +71,10 @@ bool _looksTechnical(String message) {
'null check operator',
'nosuchmethod',
'formatexception',
'could not execute statement',
'duplicate key',
'constraint',
'sql [',
];
return blocked.any(lower.contains);
}
@@ -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.');
}
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
class AppColors {
static const primary = Color(0xFF1A56DB);
static const primary = Color(0xFF2563EB);
static const primaryDark = Color(0xFF0F3EA8);
static const accent = Color(0xFF0891B2);
static const warning = Color(0xFFD97706);
static const danger = Color(0xFFDC2626);
static const success = Color(0xFF16A34A);
static const surface = Color(0xFFF8FAFC);
static const success = Color(0xFF059669);
static const surface = Color(0xFFF7FAFC);
static const surfaceRaised = Color(0xFFFFFFFF);
static const text = Color(0xFF0F172A);
static const muted = Color(0xFF64748B);
static const border = Color(0xFFE2E8F0);
}