Phase 3.1: Remove flutter_bloc + equatable, delete old bloc files

- Remove flutter_bloc and equatable dependencies from both apps
- Delete all 10 old bloc files (5 per app)
- Fix 6 remaining screens that used context.read<ApiClient>() from
  flutter_bloc → converted to ConsumerStatefulWidget/ConsumerWidget
  with ref.read(apiClientProvider)
- Both apps now use Riverpod exclusively for state management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:12:28 +08:00
parent 35d470b851
commit fa8c963d92
20 changed files with 38 additions and 2106 deletions

View File

@@ -1,145 +0,0 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class PhoneOtpRequested extends AuthEvent {
final String phone;
PhoneOtpRequested(this.phone);
@override List<Object?> get props => [phone];
}
class OtpVerified extends AuthEvent {
final String verificationId;
final String smsCode;
OtpVerified(this.verificationId, this.smsCode);
@override List<Object?> get props => [verificationId, smsCode];
}
class LogoutRequested extends AuthEvent {}
// States
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final Map<String, dynamic> profile;
AuthAuthenticated(this.profile);
@override List<Object?> get props => [profile];
}
class AuthOtpSent extends AuthState {
final String verificationId;
AuthOtpSent(this.verificationId);
@override List<Object?> get props => [verificationId];
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
@override List<Object?> get props => [message];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
ConfirmationResult? _webConfirmationResult;
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
on<AppStarted>(_onAppStarted);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
if (_auth.currentUser != null) {
await _verifyAndEmit(emit);
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
if (kIsWeb) {
try {
final confirmationResult = await _auth.signInWithPhoneNumber(event.phone);
_webConfirmationResult = confirmationResult;
emit(AuthOtpSent('web'));
} catch (e) {
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
}
} else {
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
emit(AuthOtpSent(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
);
await completer.future;
}
}
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
if (kIsWeb && _webConfirmationResult != null) {
await _webConfirmationResult!.confirm(event.smsCode);
} else {
final credential = PhoneAuthProvider.credential(
verificationId: event.verificationId,
smsCode: event.smsCode,
);
await _auth.signInWithCredential(credential);
}
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('OTP tidak valid. Coba lagi.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/mitra/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} on Exception catch (e) {
await _auth.signOut();
// Surface specific errors from backend
final msg = e.toString();
if (msg.contains('ACCOUNT_NOT_FOUND')) {
emit(AuthError('Akun tidak ditemukan. Hubungi administrator.'));
} else if (msg.contains('ACCOUNT_INACTIVE')) {
emit(AuthError('Akun tidak aktif. Hubungi administrator.'));
} else {
emit(AuthError('Gagal masuk. Coba lagi.'));
}
}
}
}

View File

@@ -1,195 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../constants.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 _ConnectionError extends ChatRequestEvent {}
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;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
on<StartListening>(_onStartListening);
on<StopListening>(_onStopListening);
on<_RequestReceived>(_onRequestReceived);
on<_ConnectionError>(_onConnectionError);
on<AcceptRequest>(_onAcceptRequest);
on<DeclineRequest>(_onDeclineRequest);
}
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
_closeWebSocket();
emit(ChatRequestListening());
await _connectWebSocket();
}
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
_closeWebSocket();
emit(ChatRequestIdle());
}
Future<void> _connectWebSocket() async {
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return; // Auth confirmed, no action needed
add(_RequestReceived(data));
},
onError: (_) => add(_ConnectionError()),
onDone: () => add(_ConnectionError()),
);
// Authenticate without session_id — just for receiving notifications
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
} catch (_) {
add(_ConnectionError());
}
}
Future<void> _onConnectionError(_ConnectionError event, Emitter<ChatRequestState> emit) async {
_closeWebSocket();
// Stay in listening state — FCM will still deliver notifications
if (state is! ChatRequestIdle) {
emit(ChatRequestListening());
}
}
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == WsMessage.chatRequest) {
emit(ChatRequestIncoming(data['session_id'] as String));
} else if (type == WsMessage.chatRequestClosed) {
// 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 _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
@override
Future<void> close() {
_closeWebSocket();
return super.close();
}
}

View File

@@ -1,91 +0,0 @@
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 ExtensionEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RespondToExtension extends ExtensionEvent {
final String sessionId;
final String extensionId;
final bool accepted;
RespondToExtension({required this.sessionId, required this.extensionId, required this.accepted});
@override
List<Object?> get props => [sessionId, extensionId, accepted];
}
class SubmitGoodbye extends ExtensionEvent {
final String sessionId;
final String message;
SubmitGoodbye({required this.sessionId, required this.message});
@override
List<Object?> get props => [sessionId, message];
}
// States
abstract class ExtensionState extends Equatable {
@override
List<Object?> get props => [];
}
class ExtensionIdle extends ExtensionState {}
class ExtensionResponding extends ExtensionState {}
class ExtensionShowGoodbye extends ExtensionState {}
class ExtensionSubmitting extends ExtensionState {}
class ExtensionComplete extends ExtensionState {}
class ExtensionError extends ExtensionState {
final String message;
ExtensionError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
final ApiClient apiClient;
ExtensionBloc({required this.apiClient}) : super(ExtensionIdle()) {
on<RespondToExtension>(_onRespond);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
Future<void> _onRespond(RespondToExtension event, Emitter<ExtensionState> emit) async {
emit(ExtensionResponding());
try {
await apiClient.post('/api/mitra/chat-requests/sessions/${event.sessionId}/extend-response', data: {
'extension_id': event.extensionId,
'accepted': event.accepted,
});
if (!event.accepted) {
emit(ExtensionShowGoodbye());
} else {
emit(ExtensionIdle());
}
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'EXTENSION_RESOLVED') {
// Extension already timed out or resolved — move to goodbye
emit(ExtensionShowGoodbye());
} else {
emit(ExtensionError('Gagal merespon perpanjangan.'));
}
}
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<ExtensionState> emit) async {
emit(ExtensionSubmitting());
try {
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
'message': event.message,
});
emit(ExtensionComplete());
} catch (e) {
emit(ExtensionError('Gagal mengirim pesan penutup.'));
}
}
}

View File

@@ -1,369 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../constants.dart';
// Events
abstract class MitraChatEvent extends Equatable {
@override
List<Object?> get props => [];
}
class ConnectChat extends MitraChatEvent {
final String sessionId;
ConnectChat(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DisconnectChat extends MitraChatEvent {}
class SendMessage extends MitraChatEvent {
final String content;
SendMessage(this.content);
@override
List<Object?> get props => [content];
}
class SendTyping extends MitraChatEvent {}
class _MessageReceived extends MitraChatEvent {
final Map<String, dynamic> data;
_MessageReceived(this.data);
@override
List<Object?> get props => [data];
}
class _ConnectionError extends MitraChatEvent {}
class MarkMessagesDelivered extends MitraChatEvent {
final List<String> messageIds;
MarkMessagesDelivered(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
class MarkMessagesRead extends MitraChatEvent {
final List<String> messageIds;
MarkMessagesRead(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
// States
abstract class MitraChatState extends Equatable {
@override
List<Object?> get props => [];
}
class ChatInitial extends MitraChatState {}
class ChatConnecting extends MitraChatState {}
class ChatConnected extends MitraChatState {
final List<ChatMessage> messages;
final bool isOtherTyping;
final int? remainingSeconds;
final bool sessionExpired;
final bool sessionClosing;
final Map<String, dynamic>? extensionRequest;
ChatConnected({
required this.messages,
this.isOtherTyping = false,
this.remainingSeconds,
this.sessionExpired = false,
this.sessionClosing = false,
this.extensionRequest,
});
ChatConnected copyWith({
List<ChatMessage>? messages,
bool? isOtherTyping,
int? remainingSeconds,
bool? sessionExpired,
bool? sessionClosing,
Map<String, dynamic>? extensionRequest,
bool clearExtensionRequest = false,
}) {
return ChatConnected(
messages: messages ?? this.messages,
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
sessionExpired: sessionExpired ?? this.sessionExpired,
sessionClosing: sessionClosing ?? this.sessionClosing,
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
);
}
@override
List<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionClosing, extensionRequest];
}
class ChatError extends MitraChatState {
final String message;
ChatError(this.message);
@override
List<Object?> get props => [message];
}
// Message model
class ChatMessage {
final String id;
final String senderType;
final String content;
final String type;
final String status;
final DateTime createdAt;
ChatMessage({
required this.id,
required this.senderType,
required this.content,
this.type = MessageType.text,
this.status = MessageStatus.sent,
required this.createdAt,
});
ChatMessage copyWith({String? status}) {
return ChatMessage(
id: id,
senderType: senderType,
content: content,
type: type,
status: status ?? this.status,
createdAt: createdAt,
);
}
}
// Bloc
class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
final ApiClient apiClient;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
MitraChatBloc({required this.apiClient}) : super(ChatInitial()) {
on<ConnectChat>(_onConnect);
on<DisconnectChat>(_onDisconnect);
on<SendMessage>(_onSendMessage);
on<SendTyping>(_onSendTyping);
on<_MessageReceived>(_onMessageReceived);
on<_ConnectionError>(_onConnectionError);
on<MarkMessagesDelivered>(_onMarkDelivered);
on<MarkMessagesRead>(_onMarkRead);
}
Future<void> _onConnect(ConnectChat event, Emitter<MitraChatState> emit) async {
emit(ChatConnecting());
try {
// Check session status before connecting
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
final sessionStatus = sessionData?['status'] as String?;
if (sessionStatus == SessionStatus.completed ||
sessionStatus == SessionStatus.cancelled ||
sessionStatus == SessionStatus.expired) {
emit(ChatError('Sesi sudah berakhir.'));
return;
}
final isClosing = sessionStatus == SessionStatus.closing;
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages');
final messagesData = response['data'] as List<dynamic>;
final messages = messagesData.map((m) => ChatMessage(
id: m['id'] as String,
senderType: m['sender_type'] as String,
content: m['content'] as String,
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
)).toList();
final user = FirebaseAuth.instance.currentUser;
final token = await user?.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
add(_MessageReceived(data));
},
onError: (_) => add(_ConnectionError()),
onDone: () => add(_ConnectionError()),
);
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
'session_id': event.sessionId,
}));
emit(ChatConnected(
messages: messages,
sessionClosing: isClosing,
));
} catch (e) {
emit(ChatError('Gagal terhubung ke chat.'));
}
}
void _onDisconnect(DisconnectChat event, Emitter<MitraChatState> emit) {
_cleanup();
emit(ChatInitial());
}
void _onSendMessage(SendMessage event, Emitter<MitraChatState> emit) {
if (state is! ChatConnected || _channel == null) return;
final current = state as ChatConnected;
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = ChatMessage(
id: tempId,
senderType: UserType.mitra,
content: event.content,
status: 'sending',
createdAt: DateTime.now(),
);
emit(current.copyWith(messages: [...current.messages, msg]));
_channel!.sink.add(jsonEncode({
'type': WsMessage.message,
'content': event.content,
'_temp_id': tempId,
}));
}
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
}
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': event.messageIds}));
}
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': event.messageIds}));
}
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) {
if (state is! ChatConnected) return;
final current = state as ChatConnected;
final data = event.data;
final type = data['type'] as String?;
switch (type) {
case WsMessage.authOk:
break;
case WsMessage.message:
final msg = ChatMessage(
id: data['message_id'] as String,
senderType: data['sender_type'] as String,
content: data['content'] as String,
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
);
emit(current.copyWith(messages: [...current.messages, msg]));
add(MarkMessagesDelivered([msg.id]));
break;
case WsMessage.messageAck:
final messageId = data['message_id'] as String;
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
if (m.status == 'sending') return m.copyWith(status: status);
return m;
}).toList();
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
if (idx >= 0) {
final old = updatedMessages[idx];
updatedMessages[idx] = ChatMessage(
id: messageId,
senderType: old.senderType,
content: old.content,
type: old.type,
status: status,
createdAt: old.createdAt,
);
}
emit(current.copyWith(messages: updatedMessages));
break;
case WsMessage.messageStatus:
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
if (messageIds.contains(m.id)) return m.copyWith(status: status);
return m;
}).toList();
emit(current.copyWith(messages: updatedMessages));
break;
case WsMessage.typing:
emit(current.copyWith(isOtherTyping: true));
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () {
if (state is ChatConnected) {
emit((state as ChatConnected).copyWith(isOtherTyping: false));
}
});
break;
case WsMessage.sessionTimer:
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
break;
case WsMessage.sessionExpired:
emit(current.copyWith(sessionExpired: true));
break;
case WsMessage.extensionRequest:
emit(current.copyWith(extensionRequest: data));
break;
case WsMessage.sessionResumed:
emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true));
break;
case WsMessage.sessionClosing:
emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true));
break;
case WsMessage.sessionCompleted:
_cleanup();
break;
}
}
void _onConnectionError(_ConnectionError event, Emitter<MitraChatState> emit) {}
void _cleanup() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
_typingTimer?.cancel();
_typingTimer = null;
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}

View File

@@ -1,128 +0,0 @@
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 {
// Don't auto-offline on pause — heartbeat timeout (45s) handles truly offline mitras.
// This allows mitra to stay online when briefly switching apps.
_stopHeartbeat();
}
Future<void> _onAppResumed(AppResumed event, Emitter<StatusState> emit) async {
// Resume heartbeat if mitra was online
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
_startHeartbeat();
}
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();
}
}

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_client_provider.dart';
class ActiveSessionsScreen extends StatefulWidget {
class ActiveSessionsScreen extends ConsumerStatefulWidget {
const ActiveSessionsScreen({super.key});
@override
State<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
ConsumerState<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
}
class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
class _ActiveSessionsScreenState extends ConsumerState<ActiveSessionsScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
@@ -22,7 +22,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
Future<void> _loadSessions() async {
try {
final apiClient = context.read<ApiClient>();
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active');
setState(() {
_sessions = List<Map<String, dynamic>>.from(response['data'] as List);
@@ -48,7 +48,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
if (confirmed == true) {
try {
final apiClient = context.read<ApiClient>();
final apiClient = ref.read(apiClientProvider);
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
_loadSessions();
} catch (_) {

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_client_provider.dart';
class MitraChatHistoryScreen extends StatefulWidget {
class MitraChatHistoryScreen extends ConsumerStatefulWidget {
const MitraChatHistoryScreen({super.key});
@override
State<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
ConsumerState<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
}
class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
@@ -22,7 +22,7 @@ class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
Future<void> _loadHistory() async {
try {
final api = context.read<ApiClient>();
final api = ref.read(apiClientProvider);
final response = await api.get('/api/mitra/chat-requests/history');
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
setState(() {

View File

@@ -1,18 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_client.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart';
class MitraChatTranscriptScreen extends StatefulWidget {
class MitraChatTranscriptScreen extends ConsumerStatefulWidget {
final String sessionId;
const MitraChatTranscriptScreen({super.key, required this.sessionId});
@override
State<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
ConsumerState<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
}
class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
class _MitraChatTranscriptScreenState extends ConsumerState<MitraChatTranscriptScreen> {
List<Map<String, dynamic>> _messages = [];
List<Map<String, dynamic>> _closures = [];
bool _loading = true;
@@ -25,7 +25,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
Future<void> _loadTranscript() async {
try {
final api = context.read<ApiClient>();
final api = ref.read(apiClientProvider);
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
final data = response['data'] as Map<String, dynamic>;
setState(() {

View File

@@ -49,14 +49,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
bloc:
dependency: transitive
description:
name: bloc
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
url: "https://pub.dev"
source: hosted
version: "8.1.4"
boolean_selector:
dependency: transitive
description:
@@ -265,14 +257,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
equatable:
dependency: "direct main"
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
@@ -382,14 +366,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_bloc:
dependency: "direct main"
description:
name: flutter_bloc
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a
url: "https://pub.dev"
source: hosted
version: "8.1.6"
flutter_hooks:
dependency: "direct main"
description:
@@ -632,14 +608,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
package_config:
dependency: transitive
description:
@@ -680,14 +648,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider:
dependency: transitive
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:

View File

@@ -21,8 +21,6 @@ dependencies:
web_socket_channel: ^2.4.5
# State management
flutter_bloc: ^8.1.5
equatable: ^2.0.5
flutter_riverpod: ^2.6.1
hooks_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1