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:
165
mitra_app/lib/core/chat/chat_request_bloc.dart
Normal file
165
mitra_app/lib/core/chat/chat_request_bloc.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user