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