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