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,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<void> _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<String, dynamic>) {
state = data['unread_count'] as int? ?? 0;
} else {
state = 0;
}
} catch (_) {}
}
void markRead() {
state = 0;
}
void refresh() => _fetchUnreadCount();
}

View File

@@ -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<UnreadCount, int>.internal(
UnreadCount.new,
name: r'unreadCountProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$unreadCountHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$UnreadCount = Notifier<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,17 @@ 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');
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');
}
}
}