Phase 2 scaffold: mitra online status & pairing logic

Add mitra online/offline status with heartbeat-based auto-offline,
customer-mitra pairing via Valkey pub/sub blast, session management,
and control center dashboard with real-time stats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 23:17:49 +08:00
parent a7a2a32d27
commit d668112edd
44 changed files with 2800 additions and 80 deletions

View File

@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class ChatRequestEvent extends Equatable {
@override
List<Object?> get props => [];
}
class StartListening extends ChatRequestEvent {}
class StopListening extends ChatRequestEvent {}
class _RequestReceived extends ChatRequestEvent {
final Map<String, dynamic> data;
_RequestReceived(this.data);
@override
List<Object?> get props => [data];
}
class AcceptRequest extends ChatRequestEvent {
final String sessionId;
AcceptRequest(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DeclineRequest extends ChatRequestEvent {
final String sessionId;
DeclineRequest(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
// States
abstract class ChatRequestState extends Equatable {
@override
List<Object?> get props => [];
}
class ChatRequestIdle extends ChatRequestState {}
class ChatRequestListening extends ChatRequestState {}
class ChatRequestIncoming extends ChatRequestState {
final String sessionId;
ChatRequestIncoming(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class ChatRequestAccepting extends ChatRequestState {}
class ChatRequestAccepted extends ChatRequestState {
final Map<String, dynamic> session;
ChatRequestAccepted(this.session);
@override
List<Object?> get props => [session];
}
class ChatRequestError extends ChatRequestState {
final String message;
ChatRequestError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
final ApiClient apiClient;
StreamSubscription? _sseSubscription;
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
on<StartListening>(_onStartListening);
on<StopListening>(_onStopListening);
on<_RequestReceived>(_onRequestReceived);
on<AcceptRequest>(_onAcceptRequest);
on<DeclineRequest>(_onDeclineRequest);
}
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
_stopSSE();
emit(ChatRequestListening());
_listenToSSE();
}
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
_stopSSE();
emit(ChatRequestIdle());
}
void _listenToSSE() {
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
dio.get(
'/api/mitra/chat-requests/incoming',
options: Options(responseType: ResponseType.stream),
).then((response) {
final stream = response.data.stream as Stream<List<int>>;
_sseSubscription = stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.where((line) => line.startsWith('data: '))
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
.listen(
(data) => add(_RequestReceived(data)),
onError: (_) {},
);
}).catchError((_) {});
}
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == 'chat_request') {
emit(ChatRequestIncoming(data['session_id'] as String));
} else if (type == 'chat_request_closed') {
// Request was taken by another mitra or cancelled
if (state is ChatRequestIncoming) {
emit(ChatRequestListening());
}
} else if (type == 'session_rerouted') {
// A session was rerouted away from us — refresh active sessions
emit(ChatRequestListening());
} else if (type == 'session_assigned') {
// A session was force-assigned to us
emit(ChatRequestAccepted({'session_id': data['session_id']}));
}
}
Future<void> _onAcceptRequest(AcceptRequest event, Emitter<ChatRequestState> emit) async {
emit(ChatRequestAccepting());
try {
final response = await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/accept');
emit(ChatRequestAccepted(response['data'] as Map<String, dynamic>));
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'REQUEST_UNAVAILABLE') {
emit(ChatRequestListening());
} else {
emit(ChatRequestError('Gagal menerima. Coba lagi.'));
}
}
}
Future<void> _onDeclineRequest(DeclineRequest event, Emitter<ChatRequestState> emit) async {
try {
await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/decline');
} catch (_) {}
emit(ChatRequestListening());
}
void _stopSSE() {
_sseSubscription?.cancel();
_sseSubscription = null;
}
@override
Future<void> close() {
_stopSSE();
return super.close();
}
}