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

@@ -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;

View 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();
}
}