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,236 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class AnonymousLoginRequested extends AuthEvent {
final String displayName;
AnonymousLoginRequested(this.displayName);
@override List<Object?> get props => [displayName];
}
class GoogleLoginRequested extends AuthEvent {}
class AppleLoginRequested 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 LinkAccountRequested extends AuthEvent {}
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 AuthAnonymous extends AuthState {
final String customerId;
final String displayName;
AuthAnonymous({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
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];
}
class AuthForceRegister extends AuthState {
final String customerId;
final String displayName;
AuthForceRegister({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
String? _pendingVerificationId;
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
on<AppStarted>(_onAppStarted);
on<AnonymousLoginRequested>(_onAnonymousLogin);
on<GoogleLoginRequested>(_onGoogleLogin);
on<AppleLoginRequested>(_onAppleLogin);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LinkAccountRequested>(_onLinkAccount);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
final currentUser = _auth.currentUser;
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
// Anonymous Firebase user — restore anonymous state
try {
final config = await apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
if (!anonymityEnabled) {
emit(AuthForceRegister(customerId: customerId, displayName: displayName));
} else {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} catch (_) {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} else if (currentUser != null && !currentUser.isAnonymous) {
// Fully registered Firebase user
await _verifyAndEmit(emit);
} else {
emit(AuthInitial());
}
}
Future<void> _onAnonymousLogin(AnonymousLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
// Sign in anonymously with Firebase to get a real JWT
await _auth.signInAnonymously();
// Create/get customer record on backend linked to this Firebase UID
final response = await apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': event.displayName},
);
final customer = response['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('anonymous_customer_id', customer['id'] as String);
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
emit(AuthAnonymous(customerId: customer['id'] as String, displayName: customer['display_name'] as String));
} catch (e) {
emit(AuthError('Failed to continue as guest. Please try again.'));
}
}
Future<void> _onGoogleLogin(GoogleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final googleUser = await GoogleSignIn().signIn();
if (googleUser == null) { emit(AuthInitial()); return; }
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Google sign-in failed. Please try again.'));
}
}
Future<void> _onAppleLogin(AppleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
);
final oauthCredential = OAuthProvider('apple.com').credential(
idToken: appleCredential.identityToken,
accessToken: appleCredential.authorizationCode,
);
await _auth.signInWithCredential(oauthCredential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Apple sign-in failed. Please try again.'));
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {},
verificationFailed: (e) => emit(AuthError('Failed to send OTP. Please try again.')),
codeSent: (verificationId, _) {
_pendingVerificationId = verificationId;
emit(AuthOtpSent(verificationId));
},
codeAutoRetrievalTimeout: (_) {},
);
}
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final credential = PhoneAuthProvider.credential(
verificationId: event.verificationId,
smsCode: event.smsCode,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Invalid OTP. Please try again.'));
}
}
Future<void> _onLinkAccount(LinkAccountRequested event, Emitter<AuthState> emit) async {
// Called after anonymous user completes social/OTP login to link accounts
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
if (customerId == null || _auth.currentUser == null) return;
emit(AuthLoading());
try {
await apiClient.post('/api/shared/customer/link', data: {
'customer_id': customerId,
'firebase_uid': _auth.currentUser!.uid,
});
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Failed to link account. Please try again.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/client/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} catch (e) {
emit(AuthError('Failed to verify account. Please try again.'));
}
}
}

View File

@@ -1,407 +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 ChatEvent extends Equatable {
@override
List<Object?> get props => [];
}
class ConnectChat extends ChatEvent {
final String sessionId;
ConnectChat(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DisconnectChat extends ChatEvent {}
class SendMessage extends ChatEvent {
final String content;
SendMessage(this.content);
@override
List<Object?> get props => [content];
}
class SendTyping extends ChatEvent {}
class _MessageReceived extends ChatEvent {
final Map<String, dynamic> data;
_MessageReceived(this.data);
@override
List<Object?> get props => [data];
}
class _ConnectionError extends ChatEvent {}
class MarkMessagesDelivered extends ChatEvent {
final List<String> messageIds;
MarkMessagesDelivered(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
class MarkMessagesRead extends ChatEvent {
final List<String> messageIds;
MarkMessagesRead(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
// States
abstract class ChatState extends Equatable {
@override
List<Object?> get props => [];
}
class ChatInitial extends ChatState {}
class ChatConnecting extends ChatState {}
class ChatConnected extends ChatState {
final List<ChatMessage> messages;
final bool isOtherTyping;
final int? remainingSeconds;
final bool sessionExpired;
final bool sessionPaused;
final bool sessionClosing;
final Map<String, dynamic>? extensionResponse;
ChatConnected({
required this.messages,
this.isOtherTyping = false,
this.remainingSeconds,
this.sessionExpired = false,
this.sessionPaused = false,
this.sessionClosing = false,
this.extensionResponse,
});
ChatConnected copyWith({
List<ChatMessage>? messages,
bool? isOtherTyping,
int? remainingSeconds,
bool? sessionExpired,
bool? sessionPaused,
bool? sessionClosing,
Map<String, dynamic>? extensionResponse,
}) {
return ChatConnected(
messages: messages ?? this.messages,
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
sessionExpired: sessionExpired ?? this.sessionExpired,
sessionPaused: sessionPaused ?? this.sessionPaused,
sessionClosing: sessionClosing ?? this.sessionClosing,
extensionResponse: extensionResponse ?? this.extensionResponse,
);
}
@override
List<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionPaused, sessionClosing, extensionResponse];
}
class ChatError extends ChatState {
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; // sending, sent, delivered, read
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 ChatBloc extends Bloc<ChatEvent, ChatState> {
final ApiClient apiClient;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
ChatBloc({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<ChatState> 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;
// Load existing messages from API
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();
// Connect WebSocket
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()),
);
// Send auth message
_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<ChatState> emit) {
_cleanup();
emit(ChatInitial());
}
void _onSendMessage(SendMessage event, Emitter<ChatState> emit) {
if (state is! ChatConnected || _channel == null) return;
final current = state as ChatConnected;
// Add message locally with 'sending' status
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = ChatMessage(
id: tempId,
senderType: UserType.customer,
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<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
}
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': WsMessage.delivered,
'message_ids': event.messageIds,
}));
}
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': WsMessage.read,
'message_ids': event.messageIds,
}));
}
void _onMessageReceived(_MessageReceived event, Emitter<ChatState> 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:
// Already connected
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]));
// Auto-acknowledge delivery
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();
// Replace temp ID with real ID
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
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:
final remaining = data['remaining_seconds'] as int?;
emit(current.copyWith(remainingSeconds: remaining));
break;
case WsMessage.sessionExpired:
emit(current.copyWith(sessionExpired: true));
break;
case WsMessage.sessionPaused:
emit(current.copyWith(sessionPaused: true));
break;
case WsMessage.sessionResumed:
emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false));
break;
case WsMessage.sessionClosing:
emit(current.copyWith(sessionClosing: true));
break;
case WsMessage.extensionResponse:
final accepted = data['accepted'] as bool? ?? false;
emit(current.copyWith(
extensionResponse: data,
sessionPaused: accepted ? false : current.sessionPaused,
sessionExpired: accepted ? false : current.sessionExpired,
));
break;
case WsMessage.sessionCompleted:
_cleanup();
break;
case WsMessage.error:
// Keep connected but show error
break;
}
}
void _onConnectionError(_ConnectionError event, Emitter<ChatState> emit) {
// Could implement reconnection logic here
}
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,87 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class ChatOpeningEvent extends Equatable {
@override
List<Object?> get props => [];
}
class LoadPricing extends ChatOpeningEvent {}
// States
abstract class ChatOpeningState extends Equatable {
@override
List<Object?> get props => [];
}
class PricingInitial extends ChatOpeningState {}
class PricingLoading extends ChatOpeningState {}
class PricingLoaded extends ChatOpeningState {
final List<PriceTier> tiers;
final bool freeTrialEligible;
final int freeTrialDurationMinutes;
PricingLoaded({
required this.tiers,
required this.freeTrialEligible,
this.freeTrialDurationMinutes = 5,
});
@override
List<Object?> get props => [tiers, freeTrialEligible, freeTrialDurationMinutes];
}
class PricingError extends ChatOpeningState {
final String message;
PricingError(this.message);
@override
List<Object?> get props => [message];
}
// Model
class PriceTier {
final int durationMinutes;
final int price;
final String label;
PriceTier({required this.durationMinutes, required this.price, required this.label});
factory PriceTier.fromJson(Map<String, dynamic> json) {
return PriceTier(
durationMinutes: json['duration_minutes'] as int,
price: json['price'] as int,
label: json['label'] as String,
);
}
}
// Bloc
class ChatOpeningBloc extends Bloc<ChatOpeningEvent, ChatOpeningState> {
final ApiClient apiClient;
ChatOpeningBloc({required this.apiClient}) : super(PricingInitial()) {
on<LoadPricing>(_onLoadPricing);
}
Future<void> _onLoadPricing(LoadPricing event, Emitter<ChatOpeningState> emit) async {
emit(PricingLoading());
try {
final response = await apiClient.get('/api/client/chat/pricing');
final data = response['data'] as Map<String, dynamic>;
final tiersJson = data['tiers'] as List<dynamic>;
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
final freeTrial = data['free_trial'] as Map<String, dynamic>;
emit(PricingLoaded(
tiers: tiers,
freeTrialEligible: freeTrial['eligible'] as bool? ?? false,
freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5,
));
} catch (e) {
emit(PricingError('Gagal memuat harga. Coba lagi.'));
}
}
}

View File

@@ -1,96 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class SessionClosureEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RequestExtension extends SessionClosureEvent {
final String sessionId;
final int durationMinutes;
final int price;
RequestExtension({required this.sessionId, required this.durationMinutes, required this.price});
@override
List<Object?> get props => [sessionId, durationMinutes, price];
}
class DeclineExtension extends SessionClosureEvent {}
class ResetClosure extends SessionClosureEvent {}
class SubmitGoodbye extends SessionClosureEvent {
final String sessionId;
final String message;
SubmitGoodbye({required this.sessionId, required this.message});
@override
List<Object?> get props => [sessionId, message];
}
// States
abstract class SessionClosureState extends Equatable {
@override
List<Object?> get props => [];
}
class ClosureInitial extends SessionClosureState {}
class ExtendingWaitingMitra extends SessionClosureState {}
class ClosureShowGoodbye extends SessionClosureState {}
class ClosureSubmitting extends SessionClosureState {}
class ClosureComplete extends SessionClosureState {}
class ClosureError extends SessionClosureState {
final String message;
ClosureError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState> {
final ApiClient apiClient;
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
on<RequestExtension>(_onRequestExtension);
on<DeclineExtension>(_onDeclineExtension);
on<ResetClosure>(_onReset);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
Future<void> _onRequestExtension(RequestExtension event, Emitter<SessionClosureState> emit) async {
emit(ExtendingWaitingMitra());
try {
await apiClient.post('/api/client/chat/session/${event.sessionId}/extend', data: {
'duration_minutes': event.durationMinutes,
'price': event.price,
});
// Response will come via WebSocket (ChatBloc handles it)
} catch (e) {
emit(ClosureError('Gagal meminta perpanjangan.'));
}
}
void _onDeclineExtension(DeclineExtension event, Emitter<SessionClosureState> emit) {
emit(ClosureShowGoodbye());
}
void _onReset(ResetClosure event, Emitter<SessionClosureState> emit) {
emit(ClosureInitial());
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
emit(ClosureSubmitting());
try {
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
'message': event.message,
});
emit(ClosureComplete());
} catch (e) {
emit(ClosureError('Gagal mengirim pesan penutup.'));
}
}
}

View File

@@ -1,230 +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 PairingEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RequestPairing extends PairingEvent {}
class RequestPairingWithTier extends PairingEvent {
final int? durationMinutes;
final int? price;
final bool isFreeTrial;
RequestPairingWithTier({this.durationMinutes, this.price, this.isFreeTrial = false});
@override
List<Object?> get props => [durationMinutes, price, isFreeTrial];
}
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 {}
class _ConnectionError 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;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
on<RequestPairing>(_onRequestPairing);
on<RequestPairingWithTier>(_onRequestPairingWithTier);
on<CancelPairing>(_onCancelPairing);
on<_PairingStatusUpdate>(_onStatusUpdate);
on<_PairingTimeout>(_onTimeout);
on<_ConnectionError>(_onConnectionError);
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
await _doPairingRequest(emit, {});
}
Future<void> _onRequestPairingWithTier(RequestPairingWithTier event, Emitter<PairingState> emit) async {
final body = <String, dynamic>{};
if (event.isFreeTrial) {
body['is_free_trial'] = true;
} else {
body['duration_minutes'] = event.durationMinutes;
body['price'] = event.price;
}
await _doPairingRequest(emit, body);
}
Future<void> _doPairingRequest(Emitter<PairingState> emit, Map<String, dynamic> body) async {
if (state is! PairingInitial) {
emit(PairingInitial());
}
try {
// Connect to WebSocket first to listen for pairing status
await _connectWebSocket();
final response = await apiClient.post('/api/client/chat/request', data: body);
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
emit(PairingSearching(sessionId));
_timeoutTimer = Timer(const Duration(seconds: 60), () {
add(_PairingTimeout());
});
} on DioException catch (e) {
_cleanup();
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 if (code == 'FREE_TRIAL_INELIGIBLE') {
emit(PairingError('Kamu tidak memenuhi syarat untuk free trial.'));
} else {
emit(PairingError('Gagal memulai. Coba lagi.'));
}
}
}
Future<void> _connectWebSocket() async {
_closeWebSocket();
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;
add(_PairingStatusUpdate(data));
},
onError: (_) => add(_ConnectionError()),
onDone: () => add(_ConnectionError()),
);
// Authenticate without session_id — just for receiving pairing status
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
}
Future<void> _onConnectionError(_ConnectionError event, Emitter<PairingState> emit) async {
// WebSocket disconnected during pairing — stay in current state,
// FCM will still deliver notifications
}
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == WsMessage.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 == SessionStatus.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 _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_closeWebSocket();
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}