Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history)
- Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services - Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history - Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history - Control center: free trial, extension timeout, early end config toggles - DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
385
client_app/lib/core/chat/chat_bloc.dart
Normal file
385
client_app/lib/core/chat/chat_bloc.dart
Normal file
@@ -0,0 +1,385 @@
|
||||
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';
|
||||
|
||||
// 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 = 'text',
|
||||
this.status = '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 {
|
||||
// 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? ?? 'text',
|
||||
status: m['status'] as String? ?? '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': 'auth',
|
||||
'token': token,
|
||||
'session_id': event.sessionId,
|
||||
}));
|
||||
|
||||
emit(ChatConnected(messages: messages));
|
||||
} 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: 'customer',
|
||||
content: event.content,
|
||||
status: 'sending',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'message',
|
||||
'content': event.content,
|
||||
'_temp_id': tempId,
|
||||
}));
|
||||
}
|
||||
|
||||
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
||||
}
|
||||
|
||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'delivered',
|
||||
'message_ids': event.messageIds,
|
||||
}));
|
||||
}
|
||||
|
||||
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': '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 'auth_ok':
|
||||
// Already connected
|
||||
break;
|
||||
|
||||
case '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? ?? 'text',
|
||||
status: '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 'message_ack':
|
||||
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 == '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 'message_status':
|
||||
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 '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 'session_timer':
|
||||
final remaining = data['remaining_seconds'] as int?;
|
||||
emit(current.copyWith(remainingSeconds: remaining));
|
||||
break;
|
||||
|
||||
case 'session_expired':
|
||||
emit(current.copyWith(sessionExpired: true));
|
||||
break;
|
||||
|
||||
case 'session_paused':
|
||||
emit(current.copyWith(sessionPaused: true));
|
||||
break;
|
||||
|
||||
case 'session_resumed':
|
||||
emit(current.copyWith(sessionPaused: false, sessionExpired: false));
|
||||
break;
|
||||
|
||||
case 'session_closing':
|
||||
emit(current.copyWith(sessionClosing: true));
|
||||
break;
|
||||
|
||||
case 'extension_response':
|
||||
emit(current.copyWith(extensionResponse: data));
|
||||
break;
|
||||
|
||||
case 'session_completed':
|
||||
_cleanup();
|
||||
break;
|
||||
|
||||
case '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();
|
||||
}
|
||||
}
|
||||
87
client_app/lib/core/chat/chat_opening_bloc.dart
Normal file
87
client_app/lib/core/chat/chat_opening_bloc.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
90
client_app/lib/core/chat/session_closure_bloc.dart
Normal file
90
client_app/lib/core/chat/session_closure_bloc.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
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 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<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());
|
||||
}
|
||||
|
||||
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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user