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) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:29:06 +08:00
parent ed765d230c
commit 229f889551
10 changed files with 261 additions and 21 deletions

View File

@@ -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<String, int> 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<void> _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<dynamic>;
final counts = <String, int>{};
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();
}

View File

@@ -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<UnreadSessions, Map<String, int>>.internal(
UnreadSessions.new,
name: r'unreadSessionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$unreadSessionsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$UnreadSessions = Notifier<Map<String, int>>;
// 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

View File

@@ -84,12 +84,21 @@ class NotificationService {
}
static void _navigateFromMessage(Map<String, dynamic> 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'});
}
}
}

View File

@@ -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<String, dynamic>;
_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();
});
}

View File

@@ -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'),