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:
@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
class ApiClient {
|
||||
static const String _baseUrl = String.fromEnvironment(
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://api.halobestie.com',
|
||||
);
|
||||
@@ -10,7 +10,7 @@ class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(BaseOptions(baseUrl: _baseUrl));
|
||||
_dio = Dio(BaseOptions(baseUrl: baseUrl));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
@@ -23,6 +23,11 @@ class ApiClient {
|
||||
));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
129
mitra_app/lib/core/status/status_bloc.dart
Normal file
129
mitra_app/lib/core/status/status_bloc.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../api/api_client.dart';
|
||||
|
||||
// Events
|
||||
abstract class StatusEvent extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StatusLoadRequested extends StatusEvent {}
|
||||
class ToggleOnline extends StatusEvent {}
|
||||
class ToggleOffline extends StatusEvent {}
|
||||
class HeartbeatTick extends StatusEvent {}
|
||||
class AppPaused extends StatusEvent {}
|
||||
class AppResumed extends StatusEvent {}
|
||||
|
||||
// States
|
||||
abstract class StatusState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StatusInitial extends StatusState {}
|
||||
|
||||
class StatusLoaded extends StatusState {
|
||||
final bool isOnline;
|
||||
StatusLoaded({required this.isOnline});
|
||||
@override
|
||||
List<Object?> get props => [isOnline];
|
||||
}
|
||||
|
||||
class StatusLoading extends StatusState {}
|
||||
|
||||
class StatusError extends StatusState {
|
||||
final String message;
|
||||
StatusError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class StatusBloc extends Bloc<StatusEvent, StatusState> {
|
||||
final ApiClient apiClient;
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
StatusBloc({required this.apiClient}) : super(StatusInitial()) {
|
||||
on<StatusLoadRequested>(_onLoad);
|
||||
on<ToggleOnline>(_onToggleOnline);
|
||||
on<ToggleOffline>(_onToggleOffline);
|
||||
on<HeartbeatTick>(_onHeartbeat);
|
||||
on<AppPaused>(_onAppPaused);
|
||||
on<AppResumed>(_onAppResumed);
|
||||
}
|
||||
|
||||
Future<void> _onLoad(StatusLoadRequested event, Emitter<StatusState> emit) async {
|
||||
try {
|
||||
final response = await apiClient.get('/api/mitra/status');
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
emit(StatusLoaded(isOnline: data['is_online'] as bool));
|
||||
} catch (e) {
|
||||
emit(StatusLoaded(isOnline: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onToggleOnline(ToggleOnline event, Emitter<StatusState> emit) async {
|
||||
emit(StatusLoading());
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/online');
|
||||
_startHeartbeat();
|
||||
emit(StatusLoaded(isOnline: true));
|
||||
} catch (e) {
|
||||
emit(StatusError('Gagal mengubah status. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onToggleOffline(ToggleOffline event, Emitter<StatusState> emit) async {
|
||||
emit(StatusLoading());
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/offline');
|
||||
_stopHeartbeat();
|
||||
emit(StatusLoaded(isOnline: false));
|
||||
} catch (e) {
|
||||
emit(StatusError('Gagal mengubah status. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onHeartbeat(HeartbeatTick event, Emitter<StatusState> emit) async {
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/heartbeat');
|
||||
} catch (_) {
|
||||
// Heartbeat failure is non-critical; server will auto-offline after 45s
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAppPaused(AppPaused event, Emitter<StatusState> emit) async {
|
||||
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/offline');
|
||||
} catch (_) {}
|
||||
_stopHeartbeat();
|
||||
emit(StatusLoaded(isOnline: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAppResumed(AppResumed event, Emitter<StatusState> emit) async {
|
||||
// Do NOT auto-set online on resume; mitra must explicitly toggle
|
||||
add(StatusLoadRequested());
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
add(HeartbeatTick());
|
||||
});
|
||||
}
|
||||
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stopHeartbeat();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user