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:
173
client_app/lib/core/pairing/pairing_bloc.dart
Normal file
173
client_app/lib/core/pairing/pairing_bloc.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
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 PairingEvent extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class RequestPairing extends PairingEvent {}
|
||||
class CancelPairing extends PairingEvent {}
|
||||
|
||||
class _PairingStatusUpdate extends PairingEvent {
|
||||
final Map<String, dynamic> data;
|
||||
_PairingStatusUpdate(this.data);
|
||||
@override
|
||||
List<Object?> get props => [data];
|
||||
}
|
||||
|
||||
class _PairingTimeout extends PairingEvent {}
|
||||
|
||||
// States
|
||||
abstract class PairingState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class PairingInitial extends PairingState {}
|
||||
class PairingSearching extends PairingState {
|
||||
final String sessionId;
|
||||
PairingSearching(this.sessionId);
|
||||
@override
|
||||
List<Object?> get props => [sessionId];
|
||||
}
|
||||
|
||||
class PairingBestieFound extends PairingState {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
PairingBestieFound({required this.sessionId, required this.mitraName});
|
||||
@override
|
||||
List<Object?> get props => [sessionId, mitraName];
|
||||
}
|
||||
|
||||
class PairingActive extends PairingState {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
PairingActive({required this.sessionId, required this.mitraName});
|
||||
@override
|
||||
List<Object?> get props => [sessionId, mitraName];
|
||||
}
|
||||
|
||||
class PairingNoBestie extends PairingState {}
|
||||
class PairingCancelled extends PairingState {}
|
||||
|
||||
class PairingError extends PairingState {
|
||||
final String message;
|
||||
PairingError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||
final ApiClient apiClient;
|
||||
Timer? _timeoutTimer;
|
||||
StreamSubscription? _sseSubscription;
|
||||
|
||||
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
|
||||
on<RequestPairing>(_onRequestPairing);
|
||||
on<CancelPairing>(_onCancelPairing);
|
||||
on<_PairingStatusUpdate>(_onStatusUpdate);
|
||||
on<_PairingTimeout>(_onTimeout);
|
||||
}
|
||||
|
||||
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
|
||||
try {
|
||||
final response = await apiClient.post('/api/client/chat/request');
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
final sessionId = data['id'] as String;
|
||||
|
||||
emit(PairingSearching(sessionId));
|
||||
|
||||
// Start 60s local timeout as a safety net
|
||||
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
||||
add(_PairingTimeout());
|
||||
});
|
||||
|
||||
// Listen to SSE for status updates
|
||||
_listenToSSE(sessionId);
|
||||
} on DioException catch (e) {
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'NO_MITRA_AVAILABLE') {
|
||||
emit(PairingNoBestie());
|
||||
} else if (code == 'ALREADY_ACTIVE') {
|
||||
emit(PairingError('Kamu sudah memiliki sesi aktif.'));
|
||||
} else {
|
||||
emit(PairingError('Gagal memulai. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _listenToSSE(String sessionId) {
|
||||
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
|
||||
// SSE endpoint — use responseType stream
|
||||
dio.get(
|
||||
'/api/client/chat/request/$sessionId/status',
|
||||
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(_PairingStatusUpdate(data)),
|
||||
onError: (_) {},
|
||||
);
|
||||
}).catchError((_) {});
|
||||
}
|
||||
|
||||
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
|
||||
final data = event.data;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
if (type == 'paired') {
|
||||
_cleanup();
|
||||
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
|
||||
final sessionId = data['session_id'] as String;
|
||||
emit(PairingBestieFound(sessionId: sessionId, mitraName: mitraName));
|
||||
|
||||
// Brief delay then transition to active
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
|
||||
} else if (type == 'expired') {
|
||||
_cleanup();
|
||||
emit(PairingNoBestie());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCancelPairing(CancelPairing event, Emitter<PairingState> emit) async {
|
||||
if (state is PairingSearching) {
|
||||
final sessionId = (state as PairingSearching).sessionId;
|
||||
try {
|
||||
await apiClient.post('/api/client/chat/request/$sessionId/cancel');
|
||||
} catch (_) {}
|
||||
_cleanup();
|
||||
emit(PairingCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTimeout(_PairingTimeout event, Emitter<PairingState> emit) async {
|
||||
_cleanup();
|
||||
emit(PairingNoBestie());
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_sseSubscription?.cancel();
|
||||
_sseSubscription = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_cleanup();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user