diff --git a/client_app/lib/core/auth/auth_bloc.dart b/client_app/lib/core/auth/auth_bloc.dart deleted file mode 100644 index aaf6424..0000000 --- a/client_app/lib/core/auth/auth_bloc.dart +++ /dev/null @@ -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 get props => []; -} - -class AppStarted extends AuthEvent {} -class AnonymousLoginRequested extends AuthEvent { - final String displayName; - AnonymousLoginRequested(this.displayName); - @override List get props => [displayName]; -} -class GoogleLoginRequested extends AuthEvent {} -class AppleLoginRequested extends AuthEvent {} -class PhoneOtpRequested extends AuthEvent { - final String phone; - PhoneOtpRequested(this.phone); - @override List get props => [phone]; -} -class OtpVerified extends AuthEvent { - final String verificationId; - final String smsCode; - OtpVerified(this.verificationId, this.smsCode); - @override List get props => [verificationId, smsCode]; -} -class LinkAccountRequested extends AuthEvent {} -class LogoutRequested extends AuthEvent {} - -// States -abstract class AuthState extends Equatable { - @override - List get props => []; -} - -class AuthInitial extends AuthState {} -class AuthLoading extends AuthState {} -class AuthAuthenticated extends AuthState { - final Map profile; - AuthAuthenticated(this.profile); - @override List get props => [profile]; -} -class AuthAnonymous extends AuthState { - final String customerId; - final String displayName; - AuthAnonymous({required this.customerId, required this.displayName}); - @override List get props => [customerId, displayName]; -} -class AuthOtpSent extends AuthState { - final String verificationId; - AuthOtpSent(this.verificationId); - @override List get props => [verificationId]; -} -class AuthError extends AuthState { - final String message; - AuthError(this.message); - @override List get props => [message]; -} -class AuthForceRegister extends AuthState { - final String customerId; - final String displayName; - AuthForceRegister({required this.customerId, required this.displayName}); - @override List get props => [customerId, displayName]; -} - -// Bloc -class AuthBloc extends Bloc { - final ApiClient apiClient; - final _auth = FirebaseAuth.instance; - String? _pendingVerificationId; - - AuthBloc({required this.apiClient}) : super(AuthLoading()) { - on(_onAppStarted); - on(_onAnonymousLogin); - on(_onGoogleLogin); - on(_onAppleLogin); - on(_onPhoneOtpRequested); - on(_onOtpVerified); - on(_onLinkAccount); - on(_onLogout); - } - - Future _onAppStarted(AppStarted event, Emitter 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 _onAnonymousLogin(AnonymousLoginRequested event, Emitter 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; - 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 _onGoogleLogin(GoogleLoginRequested event, Emitter 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 _onAppleLogin(AppleLoginRequested event, Emitter 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 _onPhoneOtpRequested(PhoneOtpRequested event, Emitter 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 _onOtpVerified(OtpVerified event, Emitter 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 _onLinkAccount(LinkAccountRequested event, Emitter 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 _onLogout(LogoutRequested event, Emitter 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 _verifyAndEmit(Emitter emit) async { - try { - final response = await apiClient.post('/api/client/auth/verify'); - emit(AuthAuthenticated(response['data'] as Map)); - } catch (e) { - emit(AuthError('Failed to verify account. Please try again.')); - } - } -} diff --git a/client_app/lib/core/chat/chat_bloc.dart b/client_app/lib/core/chat/chat_bloc.dart deleted file mode 100644 index 9b86b92..0000000 --- a/client_app/lib/core/chat/chat_bloc.dart +++ /dev/null @@ -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 get props => []; -} - -class ConnectChat extends ChatEvent { - final String sessionId; - ConnectChat(this.sessionId); - @override - List get props => [sessionId]; -} - -class DisconnectChat extends ChatEvent {} - -class SendMessage extends ChatEvent { - final String content; - SendMessage(this.content); - @override - List get props => [content]; -} - -class SendTyping extends ChatEvent {} - -class _MessageReceived extends ChatEvent { - final Map data; - _MessageReceived(this.data); - @override - List get props => [data]; -} - -class _ConnectionError extends ChatEvent {} - -class MarkMessagesDelivered extends ChatEvent { - final List messageIds; - MarkMessagesDelivered(this.messageIds); - @override - List get props => [messageIds]; -} - -class MarkMessagesRead extends ChatEvent { - final List messageIds; - MarkMessagesRead(this.messageIds); - @override - List get props => [messageIds]; -} - -// States -abstract class ChatState extends Equatable { - @override - List get props => []; -} - -class ChatInitial extends ChatState {} -class ChatConnecting extends ChatState {} - -class ChatConnected extends ChatState { - final List messages; - final bool isOtherTyping; - final int? remainingSeconds; - final bool sessionExpired; - final bool sessionPaused; - final bool sessionClosing; - final Map? extensionResponse; - - ChatConnected({ - required this.messages, - this.isOtherTyping = false, - this.remainingSeconds, - this.sessionExpired = false, - this.sessionPaused = false, - this.sessionClosing = false, - this.extensionResponse, - }); - - ChatConnected copyWith({ - List? messages, - bool? isOtherTyping, - int? remainingSeconds, - bool? sessionExpired, - bool? sessionPaused, - bool? sessionClosing, - Map? 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 get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionPaused, sessionClosing, extensionResponse]; -} - -class ChatError extends ChatState { - final String message; - ChatError(this.message); - @override - List 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 { - final ApiClient apiClient; - WebSocketChannel? _channel; - StreamSubscription? _wsSubscription; - Timer? _typingTimer; - - ChatBloc({required this.apiClient}) : super(ChatInitial()) { - on(_onConnect); - on(_onDisconnect); - on(_onSendMessage); - on(_onSendTyping); - on<_MessageReceived>(_onMessageReceived); - on<_ConnectionError>(_onConnectionError); - on(_onMarkDelivered); - on(_onMarkRead); - } - - Future _onConnect(ConnectChat event, Emitter 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?; - 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; - 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; - 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 emit) { - _cleanup(); - emit(ChatInitial()); - } - - void _onSendMessage(SendMessage event, Emitter 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 emit) { - if (_channel == null) return; - _channel!.sink.add(jsonEncode({'type': WsMessage.typing})); - } - - void _onMarkDelivered(MarkMessagesDelivered event, Emitter emit) { - if (_channel == null) return; - _channel!.sink.add(jsonEncode({ - 'type': WsMessage.delivered, - 'message_ids': event.messageIds, - })); - } - - void _onMarkRead(MarkMessagesRead event, Emitter emit) { - if (_channel == null) return; - _channel!.sink.add(jsonEncode({ - 'type': WsMessage.read, - 'message_ids': event.messageIds, - })); - } - - void _onMessageReceived(_MessageReceived event, Emitter 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).cast(); - 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 emit) { - // Could implement reconnection logic here - } - - void _cleanup() { - _wsSubscription?.cancel(); - _wsSubscription = null; - _channel?.sink.close(); - _channel = null; - _typingTimer?.cancel(); - _typingTimer = null; - } - - @override - Future close() { - _cleanup(); - return super.close(); - } -} diff --git a/client_app/lib/core/chat/chat_opening_bloc.dart b/client_app/lib/core/chat/chat_opening_bloc.dart deleted file mode 100644 index 0e2e5f0..0000000 --- a/client_app/lib/core/chat/chat_opening_bloc.dart +++ /dev/null @@ -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 get props => []; -} - -class LoadPricing extends ChatOpeningEvent {} - -// States -abstract class ChatOpeningState extends Equatable { - @override - List get props => []; -} - -class PricingInitial extends ChatOpeningState {} -class PricingLoading extends ChatOpeningState {} - -class PricingLoaded extends ChatOpeningState { - final List tiers; - final bool freeTrialEligible; - final int freeTrialDurationMinutes; - - PricingLoaded({ - required this.tiers, - required this.freeTrialEligible, - this.freeTrialDurationMinutes = 5, - }); - - @override - List get props => [tiers, freeTrialEligible, freeTrialDurationMinutes]; -} - -class PricingError extends ChatOpeningState { - final String message; - PricingError(this.message); - @override - List 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 json) { - return PriceTier( - durationMinutes: json['duration_minutes'] as int, - price: json['price'] as int, - label: json['label'] as String, - ); - } -} - -// Bloc -class ChatOpeningBloc extends Bloc { - final ApiClient apiClient; - - ChatOpeningBloc({required this.apiClient}) : super(PricingInitial()) { - on(_onLoadPricing); - } - - Future _onLoadPricing(LoadPricing event, Emitter emit) async { - emit(PricingLoading()); - try { - final response = await apiClient.get('/api/client/chat/pricing'); - final data = response['data'] as Map; - final tiersJson = data['tiers'] as List; - final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map)).toList(); - final freeTrial = data['free_trial'] as Map; - - 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.')); - } - } -} diff --git a/client_app/lib/core/chat/session_closure_bloc.dart b/client_app/lib/core/chat/session_closure_bloc.dart deleted file mode 100644 index 8bbf9a0..0000000 --- a/client_app/lib/core/chat/session_closure_bloc.dart +++ /dev/null @@ -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 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 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 get props => [sessionId, message]; -} - -// States -abstract class SessionClosureState extends Equatable { - @override - List 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 get props => [message]; -} - -// Bloc -class SessionClosureBloc extends Bloc { - final ApiClient apiClient; - - SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) { - on(_onRequestExtension); - on(_onDeclineExtension); - on(_onReset); - on(_onSubmitGoodbye); - } - - Future _onRequestExtension(RequestExtension event, Emitter 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 emit) { - emit(ClosureShowGoodbye()); - } - - void _onReset(ResetClosure event, Emitter emit) { - emit(ClosureInitial()); - } - - Future _onSubmitGoodbye(SubmitGoodbye event, Emitter 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.')); - } - } -} diff --git a/client_app/lib/core/pairing/pairing_bloc.dart b/client_app/lib/core/pairing/pairing_bloc.dart deleted file mode 100644 index bfce64d..0000000 --- a/client_app/lib/core/pairing/pairing_bloc.dart +++ /dev/null @@ -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 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 get props => [durationMinutes, price, isFreeTrial]; -} - -class CancelPairing extends PairingEvent {} - -class _PairingStatusUpdate extends PairingEvent { - final Map data; - _PairingStatusUpdate(this.data); - @override - List get props => [data]; -} - -class _PairingTimeout extends PairingEvent {} -class _ConnectionError extends PairingEvent {} - -// States -abstract class PairingState extends Equatable { - @override - List get props => []; -} - -class PairingInitial extends PairingState {} -class PairingSearching extends PairingState { - final String sessionId; - PairingSearching(this.sessionId); - @override - List get props => [sessionId]; -} - -class PairingBestieFound extends PairingState { - final String sessionId; - final String mitraName; - PairingBestieFound({required this.sessionId, required this.mitraName}); - @override - List get props => [sessionId, mitraName]; -} - -class PairingActive extends PairingState { - final String sessionId; - final String mitraName; - PairingActive({required this.sessionId, required this.mitraName}); - @override - List get props => [sessionId, mitraName]; -} - -class PairingNoBestie extends PairingState {} -class PairingCancelled extends PairingState {} - -class PairingError extends PairingState { - final String message; - PairingError(this.message); - @override - List get props => [message]; -} - -// Bloc -class PairingBloc extends Bloc { - final ApiClient apiClient; - Timer? _timeoutTimer; - WebSocketChannel? _channel; - StreamSubscription? _wsSubscription; - - PairingBloc({required this.apiClient}) : super(PairingInitial()) { - on(_onRequestPairing); - on(_onRequestPairingWithTier); - on(_onCancelPairing); - on<_PairingStatusUpdate>(_onStatusUpdate); - on<_PairingTimeout>(_onTimeout); - on<_ConnectionError>(_onConnectionError); - } - - Future _onRequestPairing(RequestPairing event, Emitter emit) async { - await _doPairingRequest(emit, {}); - } - - Future _onRequestPairingWithTier(RequestPairingWithTier event, Emitter emit) async { - final body = {}; - if (event.isFreeTrial) { - body['is_free_trial'] = true; - } else { - body['duration_minutes'] = event.durationMinutes; - body['price'] = event.price; - } - await _doPairingRequest(emit, body); - } - - Future _doPairingRequest(Emitter emit, Map 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; - 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 _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; - 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 _onConnectionError(_ConnectionError event, Emitter emit) async { - // WebSocket disconnected during pairing — stay in current state, - // FCM will still deliver notifications - } - - Future _onStatusUpdate(_PairingStatusUpdate event, Emitter 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 _onCancelPairing(CancelPairing event, Emitter 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 _onTimeout(_PairingTimeout event, Emitter 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 close() { - _cleanup(); - return super.close(); - } -} diff --git a/client_app/lib/features/chat/screens/chat_history_screen.dart b/client_app/lib/features/chat/screens/chat_history_screen.dart index d55a4c1..7009869 100644 --- a/client_app/lib/features/chat/screens/chat_history_screen.dart +++ b/client_app/lib/features/chat/screens/chat_history_screen.dart @@ -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 ChatHistoryScreen extends StatefulWidget { +class ChatHistoryScreen extends ConsumerStatefulWidget { const ChatHistoryScreen({super.key}); @override - State createState() => _ChatHistoryScreenState(); + ConsumerState createState() => _ChatHistoryScreenState(); } -class _ChatHistoryScreenState extends State { +class _ChatHistoryScreenState extends ConsumerState { List> _sessions = []; bool _loading = true; @@ -22,7 +22,7 @@ class _ChatHistoryScreenState extends State { Future _loadHistory() async { try { - final api = context.read(); + final api = ref.read(apiClientProvider); final response = await api.get('/api/client/chat/history'); final items = (response['data']['items'] as List).cast>(); setState(() { diff --git a/client_app/lib/features/chat/screens/chat_transcript_screen.dart b/client_app/lib/features/chat/screens/chat_transcript_screen.dart index 5cd1c48..bd4bfad 100644 --- a/client_app/lib/features/chat/screens/chat_transcript_screen.dart +++ b/client_app/lib/features/chat/screens/chat_transcript_screen.dart @@ -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 ChatTranscriptScreen extends StatefulWidget { +class ChatTranscriptScreen extends ConsumerStatefulWidget { final String sessionId; const ChatTranscriptScreen({super.key, required this.sessionId}); @override - State createState() => _ChatTranscriptScreenState(); + ConsumerState createState() => _ChatTranscriptScreenState(); } -class _ChatTranscriptScreenState extends State { +class _ChatTranscriptScreenState extends ConsumerState { List> _messages = []; List> _closures = []; bool _loading = true; @@ -25,7 +25,7 @@ class _ChatTranscriptScreenState extends State { Future _loadTranscript() async { try { - final api = context.read(); + final api = ref.read(apiClientProvider); final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript'); final data = response['data'] as Map; setState(() { diff --git a/client_app/lib/features/chat/screens/session_active_screen.dart b/client_app/lib/features/chat/screens/session_active_screen.dart index fd2fefe..b7893d7 100644 --- a/client_app/lib/features/chat/screens/session_active_screen.dart +++ b/client_app/lib/features/chat/screens/session_active_screen.dart @@ -1,9 +1,9 @@ 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 SessionActiveScreen extends StatelessWidget { +class SessionActiveScreen extends ConsumerWidget { final String sessionId; final String mitraName; @@ -14,7 +14,7 @@ class SessionActiveScreen extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar( title: const Text('Sesi Aktif'), @@ -42,7 +42,7 @@ class SessionActiveScreen extends StatelessWidget { const SizedBox(height: 48), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - onPressed: () => _endSession(context), + onPressed: () => _endSession(context, ref), child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)), ), ], @@ -52,7 +52,7 @@ class SessionActiveScreen extends StatelessWidget { ); } - Future _endSession(BuildContext context) async { + Future _endSession(BuildContext context, WidgetRef ref) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -67,7 +67,7 @@ class SessionActiveScreen extends StatelessWidget { if (confirmed == true && context.mounted) { try { - final apiClient = context.read(); + final apiClient = ref.read(apiClientProvider); await apiClient.post('/api/client/chat/session/$sessionId/end'); if (context.mounted) context.go('/home'); } catch (_) { diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index a8725b8..f1283c4 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -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: @@ -680,14 +656,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: @@ -760,14 +728,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: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index 68b5903..1e3faef 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -25,8 +25,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 diff --git a/mitra_app/lib/core/auth/auth_bloc.dart b/mitra_app/lib/core/auth/auth_bloc.dart deleted file mode 100644 index b7911ff..0000000 --- a/mitra_app/lib/core/auth/auth_bloc.dart +++ /dev/null @@ -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 get props => []; -} - -class AppStarted extends AuthEvent {} -class PhoneOtpRequested extends AuthEvent { - final String phone; - PhoneOtpRequested(this.phone); - @override List get props => [phone]; -} -class OtpVerified extends AuthEvent { - final String verificationId; - final String smsCode; - OtpVerified(this.verificationId, this.smsCode); - @override List get props => [verificationId, smsCode]; -} -class LogoutRequested extends AuthEvent {} - -// States -abstract class AuthState extends Equatable { - @override - List get props => []; -} - -class AuthInitial extends AuthState {} -class AuthLoading extends AuthState {} -class AuthAuthenticated extends AuthState { - final Map profile; - AuthAuthenticated(this.profile); - @override List get props => [profile]; -} -class AuthOtpSent extends AuthState { - final String verificationId; - AuthOtpSent(this.verificationId); - @override List get props => [verificationId]; -} -class AuthError extends AuthState { - final String message; - AuthError(this.message); - @override List get props => [message]; -} - -// Bloc -class AuthBloc extends Bloc { - final ApiClient apiClient; - final _auth = FirebaseAuth.instance; - ConfirmationResult? _webConfirmationResult; - - AuthBloc({required this.apiClient}) : super(AuthLoading()) { - on(_onAppStarted); - on(_onPhoneOtpRequested); - on(_onOtpVerified); - on(_onLogout); - } - - Future _onAppStarted(AppStarted event, Emitter emit) async { - if (_auth.currentUser != null) { - await _verifyAndEmit(emit); - } - } - - Future _onPhoneOtpRequested(PhoneOtpRequested event, Emitter 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(); - 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 _onOtpVerified(OtpVerified event, Emitter 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 _onLogout(LogoutRequested event, Emitter emit) async { - await _auth.signOut(); - emit(AuthInitial()); - } - - Future _verifyAndEmit(Emitter emit) async { - try { - final response = await apiClient.post('/api/mitra/auth/verify'); - emit(AuthAuthenticated(response['data'] as Map)); - } 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.')); - } - } - } -} diff --git a/mitra_app/lib/core/chat/chat_request_bloc.dart b/mitra_app/lib/core/chat/chat_request_bloc.dart deleted file mode 100644 index e293eb7..0000000 --- a/mitra_app/lib/core/chat/chat_request_bloc.dart +++ /dev/null @@ -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 get props => []; -} - -class StartListening extends ChatRequestEvent {} -class StopListening extends ChatRequestEvent {} - -class _RequestReceived extends ChatRequestEvent { - final Map data; - _RequestReceived(this.data); - @override - List get props => [data]; -} - -class _ConnectionError extends ChatRequestEvent {} - -class AcceptRequest extends ChatRequestEvent { - final String sessionId; - AcceptRequest(this.sessionId); - @override - List get props => [sessionId]; -} - -class DeclineRequest extends ChatRequestEvent { - final String sessionId; - DeclineRequest(this.sessionId); - @override - List get props => [sessionId]; -} - -// States -abstract class ChatRequestState extends Equatable { - @override - List get props => []; -} - -class ChatRequestIdle extends ChatRequestState {} -class ChatRequestListening extends ChatRequestState {} - -class ChatRequestIncoming extends ChatRequestState { - final String sessionId; - ChatRequestIncoming(this.sessionId); - @override - List get props => [sessionId]; -} - -class ChatRequestAccepting extends ChatRequestState {} - -class ChatRequestAccepted extends ChatRequestState { - final Map session; - ChatRequestAccepted(this.session); - @override - List get props => [session]; -} - -class ChatRequestError extends ChatRequestState { - final String message; - ChatRequestError(this.message); - @override - List get props => [message]; -} - -// Bloc -class ChatRequestBloc extends Bloc { - final ApiClient apiClient; - WebSocketChannel? _channel; - StreamSubscription? _wsSubscription; - - ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) { - on(_onStartListening); - on(_onStopListening); - on<_RequestReceived>(_onRequestReceived); - on<_ConnectionError>(_onConnectionError); - on(_onAcceptRequest); - on(_onDeclineRequest); - } - - Future _onStartListening(StartListening event, Emitter emit) async { - _closeWebSocket(); - emit(ChatRequestListening()); - await _connectWebSocket(); - } - - Future _onStopListening(StopListening event, Emitter emit) async { - _closeWebSocket(); - emit(ChatRequestIdle()); - } - - Future _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; - 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 _onConnectionError(_ConnectionError event, Emitter emit) async { - _closeWebSocket(); - // Stay in listening state — FCM will still deliver notifications - if (state is! ChatRequestIdle) { - emit(ChatRequestListening()); - } - } - - Future _onRequestReceived(_RequestReceived event, Emitter 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 _onAcceptRequest(AcceptRequest event, Emitter emit) async { - emit(ChatRequestAccepting()); - try { - final response = await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/accept'); - emit(ChatRequestAccepted(response['data'] as Map)); - } 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 _onDeclineRequest(DeclineRequest event, Emitter 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 close() { - _closeWebSocket(); - return super.close(); - } -} diff --git a/mitra_app/lib/core/chat/extension_bloc.dart b/mitra_app/lib/core/chat/extension_bloc.dart deleted file mode 100644 index e088e6f..0000000 --- a/mitra_app/lib/core/chat/extension_bloc.dart +++ /dev/null @@ -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 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 get props => [sessionId, extensionId, accepted]; -} - -class SubmitGoodbye extends ExtensionEvent { - final String sessionId; - final String message; - SubmitGoodbye({required this.sessionId, required this.message}); - @override - List get props => [sessionId, message]; -} - -// States -abstract class ExtensionState extends Equatable { - @override - List 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 get props => [message]; -} - -// Bloc -class ExtensionBloc extends Bloc { - final ApiClient apiClient; - - ExtensionBloc({required this.apiClient}) : super(ExtensionIdle()) { - on(_onRespond); - on(_onSubmitGoodbye); - } - - Future _onRespond(RespondToExtension event, Emitter 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 _onSubmitGoodbye(SubmitGoodbye event, Emitter 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.')); - } - } -} diff --git a/mitra_app/lib/core/chat/mitra_chat_bloc.dart b/mitra_app/lib/core/chat/mitra_chat_bloc.dart deleted file mode 100644 index 23f62c9..0000000 --- a/mitra_app/lib/core/chat/mitra_chat_bloc.dart +++ /dev/null @@ -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 get props => []; -} - -class ConnectChat extends MitraChatEvent { - final String sessionId; - ConnectChat(this.sessionId); - @override - List get props => [sessionId]; -} - -class DisconnectChat extends MitraChatEvent {} - -class SendMessage extends MitraChatEvent { - final String content; - SendMessage(this.content); - @override - List get props => [content]; -} - -class SendTyping extends MitraChatEvent {} - -class _MessageReceived extends MitraChatEvent { - final Map data; - _MessageReceived(this.data); - @override - List get props => [data]; -} - -class _ConnectionError extends MitraChatEvent {} - -class MarkMessagesDelivered extends MitraChatEvent { - final List messageIds; - MarkMessagesDelivered(this.messageIds); - @override - List get props => [messageIds]; -} - -class MarkMessagesRead extends MitraChatEvent { - final List messageIds; - MarkMessagesRead(this.messageIds); - @override - List get props => [messageIds]; -} - -// States -abstract class MitraChatState extends Equatable { - @override - List get props => []; -} - -class ChatInitial extends MitraChatState {} -class ChatConnecting extends MitraChatState {} - -class ChatConnected extends MitraChatState { - final List messages; - final bool isOtherTyping; - final int? remainingSeconds; - final bool sessionExpired; - final bool sessionClosing; - final Map? extensionRequest; - - ChatConnected({ - required this.messages, - this.isOtherTyping = false, - this.remainingSeconds, - this.sessionExpired = false, - this.sessionClosing = false, - this.extensionRequest, - }); - - ChatConnected copyWith({ - List? messages, - bool? isOtherTyping, - int? remainingSeconds, - bool? sessionExpired, - bool? sessionClosing, - Map? 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 get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionClosing, extensionRequest]; -} - -class ChatError extends MitraChatState { - final String message; - ChatError(this.message); - @override - List 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 { - final ApiClient apiClient; - WebSocketChannel? _channel; - StreamSubscription? _wsSubscription; - Timer? _typingTimer; - - MitraChatBloc({required this.apiClient}) : super(ChatInitial()) { - on(_onConnect); - on(_onDisconnect); - on(_onSendMessage); - on(_onSendTyping); - on<_MessageReceived>(_onMessageReceived); - on<_ConnectionError>(_onConnectionError); - on(_onMarkDelivered); - on(_onMarkRead); - } - - Future _onConnect(ConnectChat event, Emitter 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?; - 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; - 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; - 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 emit) { - _cleanup(); - emit(ChatInitial()); - } - - void _onSendMessage(SendMessage event, Emitter 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 emit) { - if (_channel == null) return; - _channel!.sink.add(jsonEncode({'type': WsMessage.typing})); - } - - void _onMarkDelivered(MarkMessagesDelivered event, Emitter emit) { - if (_channel == null) return; - _channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': event.messageIds})); - } - - void _onMarkRead(MarkMessagesRead event, Emitter emit) { - if (_channel == null) return; - _channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': event.messageIds})); - } - - void _onMessageReceived(_MessageReceived event, Emitter 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).cast(); - 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 emit) {} - - void _cleanup() { - _wsSubscription?.cancel(); - _wsSubscription = null; - _channel?.sink.close(); - _channel = null; - _typingTimer?.cancel(); - _typingTimer = null; - } - - @override - Future close() { - _cleanup(); - return super.close(); - } -} diff --git a/mitra_app/lib/core/status/status_bloc.dart b/mitra_app/lib/core/status/status_bloc.dart deleted file mode 100644 index 1bebb73..0000000 --- a/mitra_app/lib/core/status/status_bloc.dart +++ /dev/null @@ -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 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 get props => []; -} - -class StatusInitial extends StatusState {} - -class StatusLoaded extends StatusState { - final bool isOnline; - StatusLoaded({required this.isOnline}); - @override - List get props => [isOnline]; -} - -class StatusLoading extends StatusState {} - -class StatusError extends StatusState { - final String message; - StatusError(this.message); - @override - List get props => [message]; -} - -// Bloc -class StatusBloc extends Bloc { - final ApiClient apiClient; - Timer? _heartbeatTimer; - - StatusBloc({required this.apiClient}) : super(StatusInitial()) { - on(_onLoad); - on(_onToggleOnline); - on(_onToggleOffline); - on(_onHeartbeat); - on(_onAppPaused); - on(_onAppResumed); - } - - Future _onLoad(StatusLoadRequested event, Emitter emit) async { - try { - final response = await apiClient.get('/api/mitra/status'); - final data = response['data'] as Map; - emit(StatusLoaded(isOnline: data['is_online'] as bool)); - } catch (e) { - emit(StatusLoaded(isOnline: false)); - } - } - - Future _onToggleOnline(ToggleOnline event, Emitter 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 _onToggleOffline(ToggleOffline event, Emitter 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 _onHeartbeat(HeartbeatTick event, Emitter emit) async { - try { - await apiClient.post('/api/mitra/status/heartbeat'); - } catch (_) { - // Heartbeat failure is non-critical; server will auto-offline after 45s - } - } - - Future _onAppPaused(AppPaused event, Emitter 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 _onAppResumed(AppResumed event, Emitter 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 close() { - _stopHeartbeat(); - return super.close(); - } -} diff --git a/mitra_app/lib/features/chat/screens/active_sessions_screen.dart b/mitra_app/lib/features/chat/screens/active_sessions_screen.dart index 6b7e3d8..a6325d1 100644 --- a/mitra_app/lib/features/chat/screens/active_sessions_screen.dart +++ b/mitra_app/lib/features/chat/screens/active_sessions_screen.dart @@ -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 createState() => _ActiveSessionsScreenState(); + ConsumerState createState() => _ActiveSessionsScreenState(); } -class _ActiveSessionsScreenState extends State { +class _ActiveSessionsScreenState extends ConsumerState { List> _sessions = []; bool _loading = true; @@ -22,7 +22,7 @@ class _ActiveSessionsScreenState extends State { Future _loadSessions() async { try { - final apiClient = context.read(); + final apiClient = ref.read(apiClientProvider); final response = await apiClient.get('/api/mitra/chat-requests/sessions/active'); setState(() { _sessions = List>.from(response['data'] as List); @@ -48,7 +48,7 @@ class _ActiveSessionsScreenState extends State { if (confirmed == true) { try { - final apiClient = context.read(); + final apiClient = ref.read(apiClientProvider); await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end'); _loadSessions(); } catch (_) { diff --git a/mitra_app/lib/features/chat/screens/chat_history_screen.dart b/mitra_app/lib/features/chat/screens/chat_history_screen.dart index bf3ad01..98ad98d 100644 --- a/mitra_app/lib/features/chat/screens/chat_history_screen.dart +++ b/mitra_app/lib/features/chat/screens/chat_history_screen.dart @@ -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 createState() => _MitraChatHistoryScreenState(); + ConsumerState createState() => _MitraChatHistoryScreenState(); } -class _MitraChatHistoryScreenState extends State { +class _MitraChatHistoryScreenState extends ConsumerState { List> _sessions = []; bool _loading = true; @@ -22,7 +22,7 @@ class _MitraChatHistoryScreenState extends State { Future _loadHistory() async { try { - final api = context.read(); + final api = ref.read(apiClientProvider); final response = await api.get('/api/mitra/chat-requests/history'); final items = (response['data']['items'] as List).cast>(); setState(() { diff --git a/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart b/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart index 337e96a..93c4665 100644 --- a/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart +++ b/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart @@ -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 createState() => _MitraChatTranscriptScreenState(); + ConsumerState createState() => _MitraChatTranscriptScreenState(); } -class _MitraChatTranscriptScreenState extends State { +class _MitraChatTranscriptScreenState extends ConsumerState { List> _messages = []; List> _closures = []; bool _loading = true; @@ -25,7 +25,7 @@ class _MitraChatTranscriptScreenState extends State { Future _loadTranscript() async { try { - final api = context.read(); + final api = ref.read(apiClientProvider); final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript'); final data = response['data'] as Map; setState(() { diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index 6024c37..2ecf67e 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -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: diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 9f34e3e..73b0ff2 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -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