From 229f88955139a50c8b1f6ca5466ff637d6292579 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 9 Apr 2026 14:29:06 +0800 Subject: [PATCH] Phase 3.1 WS2: FCM fallback Flutter + CC, unread badges, dynamic ping - Control center: add mitra ping config UI (require ping toggle + interval) - Mitra app StatusNotifier: honor require_ping and ping_interval_seconds from API; skip heartbeat when ping not required - Both apps: update notification services for FCM deep-linking - mitra_app: handle chat_request (open_accept), session_closing - client_app: handle session_closing, paired - Unread badge providers: - mitra_app: UnreadSessions provider (polls active-with-unread, badge on active sessions button) - client_app: UnreadCount provider (polls active-with-unread, badge on _ActiveSessionCard) Co-Authored-By: Claude Opus 4.6 (1M context) --- client_app/lib/core/chat/unread_notifier.dart | 50 +++++++++++++++++ .../lib/core/chat/unread_notifier.g.dart | 24 ++++++++ .../notifications/notification_service.dart | 13 +++-- client_app/lib/features/home/home_screen.dart | 16 ++++-- .../src/pages/settings/SettingsPage.jsx | 56 ++++++++++++++++++- mitra_app/lib/core/chat/unread_notifier.dart | 54 ++++++++++++++++++ .../lib/core/chat/unread_notifier.g.dart | 26 +++++++++ .../notifications/notification_service.dart | 17 ++++-- .../lib/core/status/status_notifier.dart | 12 ++-- mitra_app/lib/features/home/home_screen.dart | 14 ++++- 10 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 client_app/lib/core/chat/unread_notifier.dart create mode 100644 client_app/lib/core/chat/unread_notifier.g.dart create mode 100644 mitra_app/lib/core/chat/unread_notifier.dart create mode 100644 mitra_app/lib/core/chat/unread_notifier.g.dart diff --git a/client_app/lib/core/chat/unread_notifier.dart b/client_app/lib/core/chat/unread_notifier.dart new file mode 100644 index 0000000..c08edaa --- /dev/null +++ b/client_app/lib/core/chat/unread_notifier.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../api/api_client_provider.dart'; + +part 'unread_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class UnreadCount extends _$UnreadCount { + Timer? _pollTimer; + + @override + int build() { + _startPolling(); + ref.onDispose(_stopPolling); + return 0; + } + + void _startPolling() { + _stopPolling(); + _fetchUnreadCount(); + _pollTimer = Timer.periodic(const Duration(seconds: 15), (_) { + _fetchUnreadCount(); + }); + } + + void _stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + Future _fetchUnreadCount() async { + try { + final apiClient = ref.read(apiClientProvider); + final response = await apiClient.get('/api/client/chat/session/active-with-unread'); + final data = response['data']; + if (data is Map) { + state = data['unread_count'] as int? ?? 0; + } else { + state = 0; + } + } catch (_) {} + } + + void markRead() { + state = 0; + } + + void refresh() => _fetchUnreadCount(); +} diff --git a/client_app/lib/core/chat/unread_notifier.g.dart b/client_app/lib/core/chat/unread_notifier.g.dart new file mode 100644 index 0000000..f4225f3 --- /dev/null +++ b/client_app/lib/core/chat/unread_notifier.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'unread_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$unreadCountHash() => r'6a0b31b86ae616177f54346392d9675f916a7bec'; + +/// See also [UnreadCount]. +@ProviderFor(UnreadCount) +final unreadCountProvider = NotifierProvider.internal( + UnreadCount.new, + name: r'unreadCountProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$unreadCountHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$UnreadCount = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/client_app/lib/core/notifications/notification_service.dart b/client_app/lib/core/notifications/notification_service.dart index 9cb0e99..fbee897 100644 --- a/client_app/lib/core/notifications/notification_service.dart +++ b/client_app/lib/core/notifications/notification_service.dart @@ -84,12 +84,17 @@ class NotificationService { } static void _navigateFromMessage(Map data) { + if (_router == null) return; final sessionId = data['session_id'] as String?; - if (sessionId == null || _router == null) return; - final type = data['type'] as String?; - if (type == 'chat_message' || type == 'chat_request') { - _router!.push('/chat/session/$sessionId'); + + if (type == 'session_closing' || type == 'session_expired') { + // Navigate to the chat session — closure UI will show + if (sessionId != null) { + _router!.push('/chat/session/$sessionId', extra: 'Bestie'); + } + } else if ((type == 'chat_message' || type == 'paired') && sessionId != null) { + _router!.push('/chat/session/$sessionId', extra: 'Bestie'); } } } diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index bdb0cc0..1036655 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/auth/auth_notifier.dart'; import '../../core/api/api_client_provider.dart'; +import '../../core/chat/unread_notifier.dart'; import '../../core/pairing/pairing_notifier.dart'; import '../chat/widgets/pricing_bottom_sheet.dart'; @@ -133,15 +134,16 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse } } -class _ActiveSessionCard extends StatelessWidget { +class _ActiveSessionCard extends ConsumerWidget { final Map session; final VoidCallback onTap; const _ActiveSessionCard({required this.session, required this.onTap}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final mitraName = session['mitra_display_name'] as String? ?? 'Bestie'; + final unreadCount = ref.watch(unreadCountProvider); return Card( elevation: 2, @@ -152,9 +154,13 @@ class _ActiveSessionCard extends StatelessWidget { padding: const EdgeInsets.all(20), child: Row( children: [ - const CircleAvatar( - backgroundColor: Colors.green, - child: Icon(Icons.chat, color: Colors.white), + Badge( + isLabelVisible: unreadCount > 0, + label: Text('$unreadCount'), + child: const CircleAvatar( + backgroundColor: Colors.green, + child: Icon(Icons.chat, color: Colors.white), + ), ), const SizedBox(width: 16), Expanded( diff --git a/control_center/src/pages/settings/SettingsPage.jsx b/control_center/src/pages/settings/SettingsPage.jsx index 07ced32..824bff4 100644 --- a/control_center/src/pages/settings/SettingsPage.jsx +++ b/control_center/src/pages/settings/SettingsPage.jsx @@ -42,6 +42,17 @@ const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => { return res.data.data } +// Phase 3.1: Mitra Ping Config +const fetchMitraPingConfig = async () => { + const res = await apiClient.get('/internal/config/mitra-ping') + return res.data.data +} + +const updateMitraPingConfig = async (data) => { + const res = await apiClient.patch('/internal/config/mitra-ping', data) + return res.data.data +} + const fetchEarlyEndConfig = async () => { const res = await apiClient.get('/internal/config/early-end') return res.data.data @@ -101,7 +112,17 @@ export default function SettingsPage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }), }) - if (isLoading || maxLoading || ftLoading || etLoading || eeLoading) return
Loading...
+ // Phase 3.1: Mitra Ping + const { data: mpData, isLoading: mpLoading } = useQuery({ + queryKey: ['config-mitra-ping'], + queryFn: fetchMitraPingConfig, + }) + const mpMutation = useMutation({ + mutationFn: updateMitraPingConfig, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-mitra-ping'] }), + }) + + if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading) return
Loading...
return (
@@ -215,6 +236,39 @@ export default function SettingsPage() { {eeMutation.isError &&

Gagal menyimpan.

} + +
+

Mitra Online Status (Ping)

+

Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.

+ +

+ Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra. +

+
+ + { + const val = parseInt(e.target.value, 10) + if (val >= 5) mpMutation.mutate({ ping_interval_seconds: val }) + }} + disabled={mpMutation.isPending} + style={{ width: 80 }} + /> + detik +
+ {mpMutation.isError &&

Gagal menyimpan.

} +
) } diff --git a/mitra_app/lib/core/chat/unread_notifier.dart b/mitra_app/lib/core/chat/unread_notifier.dart new file mode 100644 index 0000000..b9cf74f --- /dev/null +++ b/mitra_app/lib/core/chat/unread_notifier.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../api/api_client_provider.dart'; + +part 'unread_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class UnreadSessions extends _$UnreadSessions { + Timer? _pollTimer; + + @override + Map build() { + _startPolling(); + ref.onDispose(_stopPolling); + return {}; + } + + void _startPolling() { + _stopPolling(); + _fetchUnreadCounts(); + _pollTimer = Timer.periodic(const Duration(seconds: 15), (_) { + _fetchUnreadCounts(); + }); + } + + void _stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + Future _fetchUnreadCounts() async { + try { + final apiClient = ref.read(apiClientProvider); + final response = await apiClient.get('/api/mitra/chat-requests/sessions/active-with-unread'); + final sessions = response['data'] as List; + final counts = {}; + for (final s in sessions) { + final id = s['id'] as String; + final count = s['unread_count'] as int? ?? 0; + if (count > 0) counts[id] = count; + } + state = counts; + } catch (_) {} + } + + int get totalUnread => state.values.fold(0, (a, b) => a + b); + + void markSessionRead(String sessionId) { + state = {...state}..remove(sessionId); + } + + void refresh() => _fetchUnreadCounts(); +} diff --git a/mitra_app/lib/core/chat/unread_notifier.g.dart b/mitra_app/lib/core/chat/unread_notifier.g.dart new file mode 100644 index 0000000..e811f20 --- /dev/null +++ b/mitra_app/lib/core/chat/unread_notifier.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'unread_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$unreadSessionsHash() => r'd2ff837f1e781e6aa624b3d3ca2befb0d1d258e8'; + +/// See also [UnreadSessions]. +@ProviderFor(UnreadSessions) +final unreadSessionsProvider = + NotifierProvider>.internal( + UnreadSessions.new, + name: r'unreadSessionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$unreadSessionsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$UnreadSessions = Notifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mitra_app/lib/core/notifications/notification_service.dart b/mitra_app/lib/core/notifications/notification_service.dart index 9cb0e99..bd1aca3 100644 --- a/mitra_app/lib/core/notifications/notification_service.dart +++ b/mitra_app/lib/core/notifications/notification_service.dart @@ -84,12 +84,21 @@ class NotificationService { } static void _navigateFromMessage(Map data) { + if (_router == null) return; final sessionId = data['session_id'] as String?; - if (sessionId == null || _router == null) return; - final type = data['type'] as String?; - if (type == 'chat_message' || type == 'chat_request') { - _router!.push('/chat/session/$sessionId'); + final action = data['action'] as String?; + + if (type == 'chat_request' && action == 'open_accept') { + // Navigate to home where incoming request sheet will show + _router!.go('/home'); + } else if (type == 'session_closing' || type == 'session_expired') { + // Navigate to the chat session closure screen + if (sessionId != null) { + _router!.push('/chat/session/$sessionId', extra: {'customerName': 'Customer'}); + } + } else if (type == 'chat_message' && sessionId != null) { + _router!.push('/chat/session/$sessionId', extra: {'customerName': 'Customer'}); } } } diff --git a/mitra_app/lib/core/status/status_notifier.dart b/mitra_app/lib/core/status/status_notifier.dart index 4ab2489..4d5efc2 100644 --- a/mitra_app/lib/core/status/status_notifier.dart +++ b/mitra_app/lib/core/status/status_notifier.dart @@ -30,6 +30,8 @@ class StatusErrorData extends OnlineStatusData { @Riverpod(keepAlive: true) class OnlineStatus extends _$OnlineStatus { Timer? _heartbeatTimer; + bool _requirePing = true; + int _pingIntervalSeconds = 15; @override OnlineStatusData build() => const StatusInitialData(); @@ -38,6 +40,8 @@ class OnlineStatus extends _$OnlineStatus { try { final response = await ref.read(apiClientProvider).get('/api/mitra/status'); final data = response['data'] as Map; + _requirePing = data['require_ping'] as bool? ?? true; + _pingIntervalSeconds = data['ping_interval_seconds'] as int? ?? 15; state = StatusLoadedData(isOnline: data['is_online'] as bool); } catch (e) { state = const StatusLoadedData(isOnline: false); @@ -48,7 +52,7 @@ class OnlineStatus extends _$OnlineStatus { state = const StatusLoadingData(); try { await ref.read(apiClientProvider).post('/api/mitra/status/online'); - _startHeartbeat(); + if (_requirePing) _startHeartbeat(); state = const StatusLoadedData(isOnline: true); } catch (e) { state = const StatusErrorData('Gagal mengubah status. Coba lagi.'); @@ -67,11 +71,11 @@ class OnlineStatus extends _$OnlineStatus { } void onAppPaused() { - _stopHeartbeat(); + if (_requirePing) _stopHeartbeat(); } void onAppResumed() { - if (state is StatusLoadedData && (state as StatusLoadedData).isOnline) { + if (_requirePing && state is StatusLoadedData && (state as StatusLoadedData).isOnline) { _startHeartbeat(); } load(); @@ -79,7 +83,7 @@ class OnlineStatus extends _$OnlineStatus { void _startHeartbeat() { _stopHeartbeat(); - _heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) { + _heartbeatTimer = Timer.periodic(Duration(seconds: _pingIntervalSeconds), (_) { _heartbeatTick(); }); } diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index 6b80bd3..744a1c1 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../core/auth/auth_notifier.dart'; import '../../core/status/status_notifier.dart'; import '../../core/chat/chat_request_notifier.dart'; +import '../../core/chat/unread_notifier.dart'; import '../chat/widgets/incoming_request_sheet.dart'; class HomeScreen extends ConsumerStatefulWidget { @@ -159,16 +160,23 @@ class _StatusToggle extends ConsumerWidget { } } -class _ActiveSessionsButton extends StatelessWidget { +class _ActiveSessionsButton extends ConsumerWidget { const _ActiveSessionsButton(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final unreadCounts = ref.watch(unreadSessionsProvider); + final totalUnread = unreadCounts.values.fold(0, (a, b) => a + b); + return Column( children: [ Card( child: ListTile( - leading: const Icon(Icons.chat_bubble_outline), + leading: Badge( + isLabelVisible: totalUnread > 0, + label: Text('$totalUnread'), + child: const Icon(Icons.chat_bubble_outline), + ), title: const Text('Sesi Aktif'), trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/sessions'),