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:
@@ -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.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '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});
|
const ChatHistoryScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
|
ConsumerState<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
|
class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
|
||||||
List<Map<String, dynamic>> _sessions = [];
|
List<Map<String, dynamic>> _sessions = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
|
|||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
try {
|
try {
|
||||||
final api = context.read<ApiClient>();
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.get('/api/client/chat/history');
|
final response = await api.get('/api/client/chat/history');
|
||||||
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
|
|
||||||
class ChatTranscriptScreen extends StatefulWidget {
|
class ChatTranscriptScreen extends ConsumerStatefulWidget {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
|
|
||||||
const ChatTranscriptScreen({super.key, required this.sessionId});
|
const ChatTranscriptScreen({super.key, required this.sessionId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatTranscriptScreen> createState() => _ChatTranscriptScreenState();
|
ConsumerState<ChatTranscriptScreen> createState() => _ChatTranscriptScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
|
class _ChatTranscriptScreenState extends ConsumerState<ChatTranscriptScreen> {
|
||||||
List<Map<String, dynamic>> _messages = [];
|
List<Map<String, dynamic>> _messages = [];
|
||||||
List<Map<String, dynamic>> _closures = [];
|
List<Map<String, dynamic>> _closures = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
@@ -25,7 +25,7 @@ class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
|
|||||||
|
|
||||||
Future<void> _loadTranscript() async {
|
Future<void> _loadTranscript() async {
|
||||||
try {
|
try {
|
||||||
final api = context.read<ApiClient>();
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '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 sessionId;
|
||||||
final String mitraName;
|
final String mitraName;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ class SessionActiveScreen extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Sesi Aktif'),
|
title: const Text('Sesi Aktif'),
|
||||||
@@ -42,7 +42,7 @@ class SessionActiveScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
onPressed: () => _endSession(context),
|
onPressed: () => _endSession(context, ref),
|
||||||
child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)),
|
child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -52,7 +52,7 @@ class SessionActiveScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _endSession(BuildContext context) async {
|
Future<void> _endSession(BuildContext context, WidgetRef ref) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
@@ -67,7 +67,7 @@ class SessionActiveScreen extends StatelessWidget {
|
|||||||
|
|
||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
try {
|
try {
|
||||||
final apiClient = context.read<ApiClient>();
|
final apiClient = ref.read(apiClientProvider);
|
||||||
await apiClient.post('/api/client/chat/session/$sessionId/end');
|
await apiClient.post('/api/client/chat/session/$sessionId/end');
|
||||||
if (context.mounted) context.go('/home');
|
if (context.mounted) context.go('/home');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -49,14 +49,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.1"
|
||||||
bloc:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: bloc
|
|
||||||
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "8.1.4"
|
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -265,14 +257,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -382,14 +366,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_hooks:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -680,14 +656,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
nested:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: nested
|
|
||||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -760,14 +728,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
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:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ dependencies:
|
|||||||
web_socket_channel: ^2.4.5
|
web_socket_channel: ^2.4.5
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
flutter_bloc: ^8.1.5
|
|
||||||
equatable: ^2.0.5
|
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^2.6.1
|
||||||
hooks_riverpod: ^2.6.1
|
hooks_riverpod: ^2.6.1
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import '../api/api_client.dart';
|
|
||||||
|
|
||||||
// Events
|
|
||||||
abstract class AuthEvent extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppStarted extends AuthEvent {}
|
|
||||||
class PhoneOtpRequested extends AuthEvent {
|
|
||||||
final String phone;
|
|
||||||
PhoneOtpRequested(this.phone);
|
|
||||||
@override List<Object?> get props => [phone];
|
|
||||||
}
|
|
||||||
class OtpVerified extends AuthEvent {
|
|
||||||
final String verificationId;
|
|
||||||
final String smsCode;
|
|
||||||
OtpVerified(this.verificationId, this.smsCode);
|
|
||||||
@override List<Object?> get props => [verificationId, smsCode];
|
|
||||||
}
|
|
||||||
class LogoutRequested extends AuthEvent {}
|
|
||||||
|
|
||||||
// States
|
|
||||||
abstract class AuthState extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthInitial extends AuthState {}
|
|
||||||
class AuthLoading extends AuthState {}
|
|
||||||
class AuthAuthenticated extends AuthState {
|
|
||||||
final Map<String, dynamic> profile;
|
|
||||||
AuthAuthenticated(this.profile);
|
|
||||||
@override List<Object?> get props => [profile];
|
|
||||||
}
|
|
||||||
class AuthOtpSent extends AuthState {
|
|
||||||
final String verificationId;
|
|
||||||
AuthOtpSent(this.verificationId);
|
|
||||||
@override List<Object?> get props => [verificationId];
|
|
||||||
}
|
|
||||||
class AuthError extends AuthState {
|
|
||||||
final String message;
|
|
||||||
AuthError(this.message);
|
|
||||||
@override List<Object?> get props => [message];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloc
|
|
||||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|
||||||
final ApiClient apiClient;
|
|
||||||
final _auth = FirebaseAuth.instance;
|
|
||||||
ConfirmationResult? _webConfirmationResult;
|
|
||||||
|
|
||||||
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
|
|
||||||
on<AppStarted>(_onAppStarted);
|
|
||||||
on<PhoneOtpRequested>(_onPhoneOtpRequested);
|
|
||||||
on<OtpVerified>(_onOtpVerified);
|
|
||||||
on<LogoutRequested>(_onLogout);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
|
|
||||||
if (_auth.currentUser != null) {
|
|
||||||
await _verifyAndEmit(emit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
|
|
||||||
emit(AuthLoading());
|
|
||||||
|
|
||||||
if (kIsWeb) {
|
|
||||||
try {
|
|
||||||
final confirmationResult = await _auth.signInWithPhoneNumber(event.phone);
|
|
||||||
_webConfirmationResult = confirmationResult;
|
|
||||||
emit(AuthOtpSent('web'));
|
|
||||||
} catch (e) {
|
|
||||||
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final completer = Completer<void>();
|
|
||||||
await _auth.verifyPhoneNumber(
|
|
||||||
phoneNumber: event.phone,
|
|
||||||
verificationCompleted: (_) {
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
verificationFailed: (e) {
|
|
||||||
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
codeSent: (verificationId, _) {
|
|
||||||
emit(AuthOtpSent(verificationId));
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
codeAutoRetrievalTimeout: (_) {
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await completer.future;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
|
|
||||||
emit(AuthLoading());
|
|
||||||
try {
|
|
||||||
if (kIsWeb && _webConfirmationResult != null) {
|
|
||||||
await _webConfirmationResult!.confirm(event.smsCode);
|
|
||||||
} else {
|
|
||||||
final credential = PhoneAuthProvider.credential(
|
|
||||||
verificationId: event.verificationId,
|
|
||||||
smsCode: event.smsCode,
|
|
||||||
);
|
|
||||||
await _auth.signInWithCredential(credential);
|
|
||||||
}
|
|
||||||
await _verifyAndEmit(emit);
|
|
||||||
} catch (e) {
|
|
||||||
emit(AuthError('OTP tidak valid. Coba lagi.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
|
|
||||||
await _auth.signOut();
|
|
||||||
emit(AuthInitial());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
|
|
||||||
try {
|
|
||||||
final response = await apiClient.post('/api/mitra/auth/verify');
|
|
||||||
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
|
|
||||||
} on Exception catch (e) {
|
|
||||||
await _auth.signOut();
|
|
||||||
// Surface specific errors from backend
|
|
||||||
final msg = e.toString();
|
|
||||||
if (msg.contains('ACCOUNT_NOT_FOUND')) {
|
|
||||||
emit(AuthError('Akun tidak ditemukan. Hubungi administrator.'));
|
|
||||||
} else if (msg.contains('ACCOUNT_INACTIVE')) {
|
|
||||||
emit(AuthError('Akun tidak aktif. Hubungi administrator.'));
|
|
||||||
} else {
|
|
||||||
emit(AuthError('Gagal masuk. Coba lagi.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
||||||
import '../api/api_client.dart';
|
|
||||||
import '../constants.dart';
|
|
||||||
|
|
||||||
// Events
|
|
||||||
abstract class ChatRequestEvent extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class StartListening extends ChatRequestEvent {}
|
|
||||||
class StopListening extends ChatRequestEvent {}
|
|
||||||
|
|
||||||
class _RequestReceived extends ChatRequestEvent {
|
|
||||||
final Map<String, dynamic> data;
|
|
||||||
_RequestReceived(this.data);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [data];
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConnectionError extends ChatRequestEvent {}
|
|
||||||
|
|
||||||
class AcceptRequest extends ChatRequestEvent {
|
|
||||||
final String sessionId;
|
|
||||||
AcceptRequest(this.sessionId);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [sessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeclineRequest extends ChatRequestEvent {
|
|
||||||
final String sessionId;
|
|
||||||
DeclineRequest(this.sessionId);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [sessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
// States
|
|
||||||
abstract class ChatRequestState extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatRequestIdle extends ChatRequestState {}
|
|
||||||
class ChatRequestListening extends ChatRequestState {}
|
|
||||||
|
|
||||||
class ChatRequestIncoming extends ChatRequestState {
|
|
||||||
final String sessionId;
|
|
||||||
ChatRequestIncoming(this.sessionId);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [sessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatRequestAccepting extends ChatRequestState {}
|
|
||||||
|
|
||||||
class ChatRequestAccepted extends ChatRequestState {
|
|
||||||
final Map<String, dynamic> session;
|
|
||||||
ChatRequestAccepted(this.session);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [session];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatRequestError extends ChatRequestState {
|
|
||||||
final String message;
|
|
||||||
ChatRequestError(this.message);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [message];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloc
|
|
||||||
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
|
||||||
final ApiClient apiClient;
|
|
||||||
WebSocketChannel? _channel;
|
|
||||||
StreamSubscription? _wsSubscription;
|
|
||||||
|
|
||||||
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
|
|
||||||
on<StartListening>(_onStartListening);
|
|
||||||
on<StopListening>(_onStopListening);
|
|
||||||
on<_RequestReceived>(_onRequestReceived);
|
|
||||||
on<_ConnectionError>(_onConnectionError);
|
|
||||||
on<AcceptRequest>(_onAcceptRequest);
|
|
||||||
on<DeclineRequest>(_onDeclineRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
|
|
||||||
_closeWebSocket();
|
|
||||||
emit(ChatRequestListening());
|
|
||||||
await _connectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
|
|
||||||
_closeWebSocket();
|
|
||||||
emit(ChatRequestIdle());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _connectWebSocket() async {
|
|
||||||
try {
|
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
|
||||||
if (user == null) return;
|
|
||||||
|
|
||||||
final token = await user.getIdToken();
|
|
||||||
final wsUrl = ApiClient.baseUrl
|
|
||||||
.replaceFirst('https://', 'wss://')
|
|
||||||
.replaceFirst('http://', 'ws://');
|
|
||||||
|
|
||||||
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
|
||||||
|
|
||||||
_wsSubscription = _channel!.stream.listen(
|
|
||||||
(raw) {
|
|
||||||
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
|
||||||
if (data['type'] == WsMessage.authOk) return; // Auth confirmed, no action needed
|
|
||||||
add(_RequestReceived(data));
|
|
||||||
},
|
|
||||||
onError: (_) => add(_ConnectionError()),
|
|
||||||
onDone: () => add(_ConnectionError()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Authenticate without session_id — just for receiving notifications
|
|
||||||
_channel!.sink.add(jsonEncode({
|
|
||||||
'type': WsMessage.auth,
|
|
||||||
'token': token,
|
|
||||||
}));
|
|
||||||
} catch (_) {
|
|
||||||
add(_ConnectionError());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onConnectionError(_ConnectionError event, Emitter<ChatRequestState> emit) async {
|
|
||||||
_closeWebSocket();
|
|
||||||
// Stay in listening state — FCM will still deliver notifications
|
|
||||||
if (state is! ChatRequestIdle) {
|
|
||||||
emit(ChatRequestListening());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
|
|
||||||
final data = event.data;
|
|
||||||
final type = data['type'] as String?;
|
|
||||||
|
|
||||||
if (type == WsMessage.chatRequest) {
|
|
||||||
emit(ChatRequestIncoming(data['session_id'] as String));
|
|
||||||
} else if (type == WsMessage.chatRequestClosed) {
|
|
||||||
// Request was taken by another mitra or cancelled
|
|
||||||
if (state is ChatRequestIncoming) {
|
|
||||||
emit(ChatRequestListening());
|
|
||||||
}
|
|
||||||
} else if (type == 'session_rerouted') {
|
|
||||||
// A session was rerouted away from us — refresh active sessions
|
|
||||||
emit(ChatRequestListening());
|
|
||||||
} else if (type == 'session_assigned') {
|
|
||||||
// A session was force-assigned to us
|
|
||||||
emit(ChatRequestAccepted({'session_id': data['session_id']}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onAcceptRequest(AcceptRequest event, Emitter<ChatRequestState> emit) async {
|
|
||||||
emit(ChatRequestAccepting());
|
|
||||||
try {
|
|
||||||
final response = await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/accept');
|
|
||||||
emit(ChatRequestAccepted(response['data'] as Map<String, dynamic>));
|
|
||||||
} on DioException catch (e) {
|
|
||||||
final code = e.response?.data?['error']?['code'];
|
|
||||||
if (code == 'REQUEST_UNAVAILABLE') {
|
|
||||||
emit(ChatRequestListening());
|
|
||||||
} else {
|
|
||||||
emit(ChatRequestError('Gagal menerima. Coba lagi.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onDeclineRequest(DeclineRequest event, Emitter<ChatRequestState> emit) async {
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/decline');
|
|
||||||
} catch (_) {}
|
|
||||||
emit(ChatRequestListening());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _closeWebSocket() {
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
_wsSubscription = null;
|
|
||||||
_channel?.sink.close();
|
|
||||||
_channel = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> close() {
|
|
||||||
_closeWebSocket();
|
|
||||||
return super.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import '../api/api_client.dart';
|
|
||||||
|
|
||||||
// Events
|
|
||||||
abstract class ExtensionEvent extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class RespondToExtension extends ExtensionEvent {
|
|
||||||
final String sessionId;
|
|
||||||
final String extensionId;
|
|
||||||
final bool accepted;
|
|
||||||
RespondToExtension({required this.sessionId, required this.extensionId, required this.accepted});
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [sessionId, extensionId, accepted];
|
|
||||||
}
|
|
||||||
|
|
||||||
class SubmitGoodbye extends ExtensionEvent {
|
|
||||||
final String sessionId;
|
|
||||||
final String message;
|
|
||||||
SubmitGoodbye({required this.sessionId, required this.message});
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [sessionId, message];
|
|
||||||
}
|
|
||||||
|
|
||||||
// States
|
|
||||||
abstract class ExtensionState extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExtensionIdle extends ExtensionState {}
|
|
||||||
class ExtensionResponding extends ExtensionState {}
|
|
||||||
class ExtensionShowGoodbye extends ExtensionState {}
|
|
||||||
class ExtensionSubmitting extends ExtensionState {}
|
|
||||||
class ExtensionComplete extends ExtensionState {}
|
|
||||||
|
|
||||||
class ExtensionError extends ExtensionState {
|
|
||||||
final String message;
|
|
||||||
ExtensionError(this.message);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [message];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloc
|
|
||||||
class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
|
|
||||||
final ApiClient apiClient;
|
|
||||||
|
|
||||||
ExtensionBloc({required this.apiClient}) : super(ExtensionIdle()) {
|
|
||||||
on<RespondToExtension>(_onRespond);
|
|
||||||
on<SubmitGoodbye>(_onSubmitGoodbye);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onRespond(RespondToExtension event, Emitter<ExtensionState> emit) async {
|
|
||||||
emit(ExtensionResponding());
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/mitra/chat-requests/sessions/${event.sessionId}/extend-response', data: {
|
|
||||||
'extension_id': event.extensionId,
|
|
||||||
'accepted': event.accepted,
|
|
||||||
});
|
|
||||||
if (!event.accepted) {
|
|
||||||
emit(ExtensionShowGoodbye());
|
|
||||||
} else {
|
|
||||||
emit(ExtensionIdle());
|
|
||||||
}
|
|
||||||
} on DioException catch (e) {
|
|
||||||
final code = e.response?.data?['error']?['code'];
|
|
||||||
if (code == 'EXTENSION_RESOLVED') {
|
|
||||||
// Extension already timed out or resolved — move to goodbye
|
|
||||||
emit(ExtensionShowGoodbye());
|
|
||||||
} else {
|
|
||||||
emit(ExtensionError('Gagal merespon perpanjangan.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<ExtensionState> emit) async {
|
|
||||||
emit(ExtensionSubmitting());
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
|
|
||||||
'message': event.message,
|
|
||||||
});
|
|
||||||
emit(ExtensionComplete());
|
|
||||||
} catch (e) {
|
|
||||||
emit(ExtensionError('Gagal mengirim pesan penutup.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
||||||
import '../api/api_client.dart';
|
|
||||||
import '../constants.dart';
|
|
||||||
|
|
||||||
// Events
|
|
||||||
abstract class MitraChatEvent extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ConnectChat extends MitraChatEvent {
|
|
||||||
final String sessionId;
|
|
||||||
ConnectChat(this.sessionId);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [sessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
class DisconnectChat extends MitraChatEvent {}
|
|
||||||
|
|
||||||
class SendMessage extends MitraChatEvent {
|
|
||||||
final String content;
|
|
||||||
SendMessage(this.content);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [content];
|
|
||||||
}
|
|
||||||
|
|
||||||
class SendTyping extends MitraChatEvent {}
|
|
||||||
|
|
||||||
class _MessageReceived extends MitraChatEvent {
|
|
||||||
final Map<String, dynamic> data;
|
|
||||||
_MessageReceived(this.data);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [data];
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConnectionError extends MitraChatEvent {}
|
|
||||||
|
|
||||||
class MarkMessagesDelivered extends MitraChatEvent {
|
|
||||||
final List<String> messageIds;
|
|
||||||
MarkMessagesDelivered(this.messageIds);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [messageIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
class MarkMessagesRead extends MitraChatEvent {
|
|
||||||
final List<String> messageIds;
|
|
||||||
MarkMessagesRead(this.messageIds);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [messageIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
// States
|
|
||||||
abstract class MitraChatState extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatInitial extends MitraChatState {}
|
|
||||||
class ChatConnecting extends MitraChatState {}
|
|
||||||
|
|
||||||
class ChatConnected extends MitraChatState {
|
|
||||||
final List<ChatMessage> messages;
|
|
||||||
final bool isOtherTyping;
|
|
||||||
final int? remainingSeconds;
|
|
||||||
final bool sessionExpired;
|
|
||||||
final bool sessionClosing;
|
|
||||||
final Map<String, dynamic>? extensionRequest;
|
|
||||||
|
|
||||||
ChatConnected({
|
|
||||||
required this.messages,
|
|
||||||
this.isOtherTyping = false,
|
|
||||||
this.remainingSeconds,
|
|
||||||
this.sessionExpired = false,
|
|
||||||
this.sessionClosing = false,
|
|
||||||
this.extensionRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
ChatConnected copyWith({
|
|
||||||
List<ChatMessage>? messages,
|
|
||||||
bool? isOtherTyping,
|
|
||||||
int? remainingSeconds,
|
|
||||||
bool? sessionExpired,
|
|
||||||
bool? sessionClosing,
|
|
||||||
Map<String, dynamic>? extensionRequest,
|
|
||||||
bool clearExtensionRequest = false,
|
|
||||||
}) {
|
|
||||||
return ChatConnected(
|
|
||||||
messages: messages ?? this.messages,
|
|
||||||
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
|
|
||||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
|
||||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
|
||||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
|
||||||
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionClosing, extensionRequest];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatError extends MitraChatState {
|
|
||||||
final String message;
|
|
||||||
ChatError(this.message);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [message];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message model
|
|
||||||
class ChatMessage {
|
|
||||||
final String id;
|
|
||||||
final String senderType;
|
|
||||||
final String content;
|
|
||||||
final String type;
|
|
||||||
final String status;
|
|
||||||
final DateTime createdAt;
|
|
||||||
|
|
||||||
ChatMessage({
|
|
||||||
required this.id,
|
|
||||||
required this.senderType,
|
|
||||||
required this.content,
|
|
||||||
this.type = MessageType.text,
|
|
||||||
this.status = MessageStatus.sent,
|
|
||||||
required this.createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
ChatMessage copyWith({String? status}) {
|
|
||||||
return ChatMessage(
|
|
||||||
id: id,
|
|
||||||
senderType: senderType,
|
|
||||||
content: content,
|
|
||||||
type: type,
|
|
||||||
status: status ?? this.status,
|
|
||||||
createdAt: createdAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloc
|
|
||||||
class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|
||||||
final ApiClient apiClient;
|
|
||||||
WebSocketChannel? _channel;
|
|
||||||
StreamSubscription? _wsSubscription;
|
|
||||||
Timer? _typingTimer;
|
|
||||||
|
|
||||||
MitraChatBloc({required this.apiClient}) : super(ChatInitial()) {
|
|
||||||
on<ConnectChat>(_onConnect);
|
|
||||||
on<DisconnectChat>(_onDisconnect);
|
|
||||||
on<SendMessage>(_onSendMessage);
|
|
||||||
on<SendTyping>(_onSendTyping);
|
|
||||||
on<_MessageReceived>(_onMessageReceived);
|
|
||||||
on<_ConnectionError>(_onConnectionError);
|
|
||||||
on<MarkMessagesDelivered>(_onMarkDelivered);
|
|
||||||
on<MarkMessagesRead>(_onMarkRead);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onConnect(ConnectChat event, Emitter<MitraChatState> emit) async {
|
|
||||||
emit(ChatConnecting());
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check session status before connecting
|
|
||||||
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
|
|
||||||
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
|
|
||||||
final sessionStatus = sessionData?['status'] as String?;
|
|
||||||
if (sessionStatus == SessionStatus.completed ||
|
|
||||||
sessionStatus == SessionStatus.cancelled ||
|
|
||||||
sessionStatus == SessionStatus.expired) {
|
|
||||||
emit(ChatError('Sesi sudah berakhir.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final isClosing = sessionStatus == SessionStatus.closing;
|
|
||||||
|
|
||||||
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages');
|
|
||||||
final messagesData = response['data'] as List<dynamic>;
|
|
||||||
final messages = messagesData.map((m) => ChatMessage(
|
|
||||||
id: m['id'] as String,
|
|
||||||
senderType: m['sender_type'] as String,
|
|
||||||
content: m['content'] as String,
|
|
||||||
type: m['type'] as String? ?? MessageType.text,
|
|
||||||
status: m['status'] as String? ?? MessageStatus.sent,
|
|
||||||
createdAt: DateTime.parse(m['created_at'] as String),
|
|
||||||
)).toList();
|
|
||||||
|
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
|
||||||
final token = await user?.getIdToken();
|
|
||||||
final wsUrl = ApiClient.baseUrl
|
|
||||||
.replaceFirst('https://', 'wss://')
|
|
||||||
.replaceFirst('http://', 'ws://');
|
|
||||||
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
|
||||||
|
|
||||||
_wsSubscription = _channel!.stream.listen(
|
|
||||||
(raw) {
|
|
||||||
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
|
||||||
add(_MessageReceived(data));
|
|
||||||
},
|
|
||||||
onError: (_) => add(_ConnectionError()),
|
|
||||||
onDone: () => add(_ConnectionError()),
|
|
||||||
);
|
|
||||||
|
|
||||||
_channel!.sink.add(jsonEncode({
|
|
||||||
'type': WsMessage.auth,
|
|
||||||
'token': token,
|
|
||||||
'session_id': event.sessionId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
emit(ChatConnected(
|
|
||||||
messages: messages,
|
|
||||||
sessionClosing: isClosing,
|
|
||||||
));
|
|
||||||
} catch (e) {
|
|
||||||
emit(ChatError('Gagal terhubung ke chat.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDisconnect(DisconnectChat event, Emitter<MitraChatState> emit) {
|
|
||||||
_cleanup();
|
|
||||||
emit(ChatInitial());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSendMessage(SendMessage event, Emitter<MitraChatState> emit) {
|
|
||||||
if (state is! ChatConnected || _channel == null) return;
|
|
||||||
final current = state as ChatConnected;
|
|
||||||
|
|
||||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
final msg = ChatMessage(
|
|
||||||
id: tempId,
|
|
||||||
senderType: UserType.mitra,
|
|
||||||
content: event.content,
|
|
||||||
status: 'sending',
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
|
||||||
|
|
||||||
_channel!.sink.add(jsonEncode({
|
|
||||||
'type': WsMessage.message,
|
|
||||||
'content': event.content,
|
|
||||||
'_temp_id': tempId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
|
|
||||||
if (_channel == null) return;
|
|
||||||
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
|
|
||||||
if (_channel == null) return;
|
|
||||||
_channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': event.messageIds}));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
|
|
||||||
if (_channel == null) return;
|
|
||||||
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': event.messageIds}));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) {
|
|
||||||
if (state is! ChatConnected) return;
|
|
||||||
final current = state as ChatConnected;
|
|
||||||
final data = event.data;
|
|
||||||
final type = data['type'] as String?;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case WsMessage.authOk:
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.message:
|
|
||||||
final msg = ChatMessage(
|
|
||||||
id: data['message_id'] as String,
|
|
||||||
senderType: data['sender_type'] as String,
|
|
||||||
content: data['content'] as String,
|
|
||||||
type: data['message_type'] as String? ?? MessageType.text,
|
|
||||||
status: MessageStatus.sent,
|
|
||||||
createdAt: DateTime.parse(data['created_at'] as String),
|
|
||||||
);
|
|
||||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
|
||||||
add(MarkMessagesDelivered([msg.id]));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.messageAck:
|
|
||||||
final messageId = data['message_id'] as String;
|
|
||||||
final status = data['status'] as String;
|
|
||||||
final updatedMessages = current.messages.map((m) {
|
|
||||||
if (m.status == 'sending') return m.copyWith(status: status);
|
|
||||||
return m;
|
|
||||||
}).toList();
|
|
||||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
|
|
||||||
if (idx >= 0) {
|
|
||||||
final old = updatedMessages[idx];
|
|
||||||
updatedMessages[idx] = ChatMessage(
|
|
||||||
id: messageId,
|
|
||||||
senderType: old.senderType,
|
|
||||||
content: old.content,
|
|
||||||
type: old.type,
|
|
||||||
status: status,
|
|
||||||
createdAt: old.createdAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
emit(current.copyWith(messages: updatedMessages));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.messageStatus:
|
|
||||||
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
|
||||||
final status = data['status'] as String;
|
|
||||||
final updatedMessages = current.messages.map((m) {
|
|
||||||
if (messageIds.contains(m.id)) return m.copyWith(status: status);
|
|
||||||
return m;
|
|
||||||
}).toList();
|
|
||||||
emit(current.copyWith(messages: updatedMessages));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.typing:
|
|
||||||
emit(current.copyWith(isOtherTyping: true));
|
|
||||||
_typingTimer?.cancel();
|
|
||||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
|
||||||
if (state is ChatConnected) {
|
|
||||||
emit((state as ChatConnected).copyWith(isOtherTyping: false));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.sessionTimer:
|
|
||||||
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.sessionExpired:
|
|
||||||
emit(current.copyWith(sessionExpired: true));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.extensionRequest:
|
|
||||||
emit(current.copyWith(extensionRequest: data));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.sessionResumed:
|
|
||||||
emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.sessionClosing:
|
|
||||||
emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WsMessage.sessionCompleted:
|
|
||||||
_cleanup();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onConnectionError(_ConnectionError event, Emitter<MitraChatState> emit) {}
|
|
||||||
|
|
||||||
void _cleanup() {
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
_wsSubscription = null;
|
|
||||||
_channel?.sink.close();
|
|
||||||
_channel = null;
|
|
||||||
_typingTimer?.cancel();
|
|
||||||
_typingTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> close() {
|
|
||||||
_cleanup();
|
|
||||||
return super.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import '../api/api_client.dart';
|
|
||||||
|
|
||||||
// Events
|
|
||||||
abstract class StatusEvent extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class StatusLoadRequested extends StatusEvent {}
|
|
||||||
class ToggleOnline extends StatusEvent {}
|
|
||||||
class ToggleOffline extends StatusEvent {}
|
|
||||||
class HeartbeatTick extends StatusEvent {}
|
|
||||||
class AppPaused extends StatusEvent {}
|
|
||||||
class AppResumed extends StatusEvent {}
|
|
||||||
|
|
||||||
// States
|
|
||||||
abstract class StatusState extends Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class StatusInitial extends StatusState {}
|
|
||||||
|
|
||||||
class StatusLoaded extends StatusState {
|
|
||||||
final bool isOnline;
|
|
||||||
StatusLoaded({required this.isOnline});
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [isOnline];
|
|
||||||
}
|
|
||||||
|
|
||||||
class StatusLoading extends StatusState {}
|
|
||||||
|
|
||||||
class StatusError extends StatusState {
|
|
||||||
final String message;
|
|
||||||
StatusError(this.message);
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [message];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloc
|
|
||||||
class StatusBloc extends Bloc<StatusEvent, StatusState> {
|
|
||||||
final ApiClient apiClient;
|
|
||||||
Timer? _heartbeatTimer;
|
|
||||||
|
|
||||||
StatusBloc({required this.apiClient}) : super(StatusInitial()) {
|
|
||||||
on<StatusLoadRequested>(_onLoad);
|
|
||||||
on<ToggleOnline>(_onToggleOnline);
|
|
||||||
on<ToggleOffline>(_onToggleOffline);
|
|
||||||
on<HeartbeatTick>(_onHeartbeat);
|
|
||||||
on<AppPaused>(_onAppPaused);
|
|
||||||
on<AppResumed>(_onAppResumed);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onLoad(StatusLoadRequested event, Emitter<StatusState> emit) async {
|
|
||||||
try {
|
|
||||||
final response = await apiClient.get('/api/mitra/status');
|
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
|
||||||
emit(StatusLoaded(isOnline: data['is_online'] as bool));
|
|
||||||
} catch (e) {
|
|
||||||
emit(StatusLoaded(isOnline: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onToggleOnline(ToggleOnline event, Emitter<StatusState> emit) async {
|
|
||||||
emit(StatusLoading());
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/mitra/status/online');
|
|
||||||
_startHeartbeat();
|
|
||||||
emit(StatusLoaded(isOnline: true));
|
|
||||||
} catch (e) {
|
|
||||||
emit(StatusError('Gagal mengubah status. Coba lagi.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onToggleOffline(ToggleOffline event, Emitter<StatusState> emit) async {
|
|
||||||
emit(StatusLoading());
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/mitra/status/offline');
|
|
||||||
_stopHeartbeat();
|
|
||||||
emit(StatusLoaded(isOnline: false));
|
|
||||||
} catch (e) {
|
|
||||||
emit(StatusError('Gagal mengubah status. Coba lagi.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onHeartbeat(HeartbeatTick event, Emitter<StatusState> emit) async {
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/mitra/status/heartbeat');
|
|
||||||
} catch (_) {
|
|
||||||
// Heartbeat failure is non-critical; server will auto-offline after 45s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onAppPaused(AppPaused event, Emitter<StatusState> emit) async {
|
|
||||||
// Don't auto-offline on pause — heartbeat timeout (45s) handles truly offline mitras.
|
|
||||||
// This allows mitra to stay online when briefly switching apps.
|
|
||||||
_stopHeartbeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onAppResumed(AppResumed event, Emitter<StatusState> emit) async {
|
|
||||||
// Resume heartbeat if mitra was online
|
|
||||||
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
|
|
||||||
_startHeartbeat();
|
|
||||||
}
|
|
||||||
add(StatusLoadRequested());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startHeartbeat() {
|
|
||||||
_stopHeartbeat();
|
|
||||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
|
||||||
add(HeartbeatTick());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _stopHeartbeat() {
|
|
||||||
_heartbeatTimer?.cancel();
|
|
||||||
_heartbeatTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> close() {
|
|
||||||
_stopHeartbeat();
|
|
||||||
return super.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '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});
|
const ActiveSessionsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
|
ConsumerState<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
class _ActiveSessionsScreenState extends ConsumerState<ActiveSessionsScreen> {
|
||||||
List<Map<String, dynamic>> _sessions = [];
|
List<Map<String, dynamic>> _sessions = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
|||||||
|
|
||||||
Future<void> _loadSessions() async {
|
Future<void> _loadSessions() async {
|
||||||
try {
|
try {
|
||||||
final apiClient = context.read<ApiClient>();
|
final apiClient = ref.read(apiClientProvider);
|
||||||
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active');
|
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active');
|
||||||
setState(() {
|
setState(() {
|
||||||
_sessions = List<Map<String, dynamic>>.from(response['data'] as List);
|
_sessions = List<Map<String, dynamic>>.from(response['data'] as List);
|
||||||
@@ -48,7 +48,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
|||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
try {
|
try {
|
||||||
final apiClient = context.read<ApiClient>();
|
final apiClient = ref.read(apiClientProvider);
|
||||||
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
|
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
|
||||||
_loadSessions();
|
_loadSessions();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '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});
|
const MitraChatHistoryScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
|
ConsumerState<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
|
class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen> {
|
||||||
List<Map<String, dynamic>> _sessions = [];
|
List<Map<String, dynamic>> _sessions = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
|
|||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
try {
|
try {
|
||||||
final api = context.read<ApiClient>();
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.get('/api/mitra/chat-requests/history');
|
final response = await api.get('/api/mitra/chat-requests/history');
|
||||||
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
|
|
||||||
class MitraChatTranscriptScreen extends StatefulWidget {
|
class MitraChatTranscriptScreen extends ConsumerStatefulWidget {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
|
|
||||||
const MitraChatTranscriptScreen({super.key, required this.sessionId});
|
const MitraChatTranscriptScreen({super.key, required this.sessionId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
|
ConsumerState<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
class _MitraChatTranscriptScreenState extends ConsumerState<MitraChatTranscriptScreen> {
|
||||||
List<Map<String, dynamic>> _messages = [];
|
List<Map<String, dynamic>> _messages = [];
|
||||||
List<Map<String, dynamic>> _closures = [];
|
List<Map<String, dynamic>> _closures = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
@@ -25,7 +25,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
|||||||
|
|
||||||
Future<void> _loadTranscript() async {
|
Future<void> _loadTranscript() async {
|
||||||
try {
|
try {
|
||||||
final api = context.read<ApiClient>();
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -49,14 +49,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.1"
|
||||||
bloc:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: bloc
|
|
||||||
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "8.1.4"
|
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -265,14 +257,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -382,14 +366,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_hooks:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -632,14 +608,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
nested:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: nested
|
|
||||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -680,14 +648,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
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:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ dependencies:
|
|||||||
web_socket_channel: ^2.4.5
|
web_socket_channel: ^2.4.5
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
flutter_bloc: ^8.1.5
|
|
||||||
equatable: ^2.0.5
|
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^2.6.1
|
||||||
hooks_riverpod: ^2.6.1
|
hooks_riverpod: ^2.6.1
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
|
|||||||
Reference in New Issue
Block a user