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:
2026-04-07 23:58:11 +08:00
parent 844d7234e6
commit b4efcf14c2
47 changed files with 4361 additions and 44 deletions

View File

@@ -0,0 +1,84 @@
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());
}
} catch (e) {
emit(ExtensionError('Gagal merespon perpanjangan.'));
}
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<ExtensionState> emit) async {
emit(ExtensionSubmitting());
try {
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
'message': event.message,
});
emit(ExtensionComplete());
} catch (e) {
emit(ExtensionError('Gagal mengirim pesan penutup.'));
}
}
}

View File

@@ -0,0 +1,351 @@
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 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,
}) {
return ChatConnected(
messages: messages ?? this.messages,
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
sessionExpired: sessionExpired ?? this.sessionExpired,
sessionClosing: sessionClosing ?? this.sessionClosing,
extensionRequest: 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 = '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 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 {
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();
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': 'auth',
'token': token,
'session_id': event.sessionId,
}));
emit(ChatConnected(messages: messages));
} 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: 'mitra',
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<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'typing'}));
}
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'delivered', 'message_ids': event.messageIds}));
}
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': '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 'auth_ok':
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]));
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();
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == '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 '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':
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
break;
case 'session_expired':
emit(current.copyWith(sessionExpired: true));
break;
case 'extension_request':
emit(current.copyWith(extensionRequest: data));
break;
case 'session_resumed':
emit(current.copyWith(sessionExpired: false, extensionRequest: null));
break;
case 'session_closing':
emit(current.copyWith(sessionClosing: true));
break;
case 'session_completed':
_cleanup();
break;
}
}
void _onConnectionError(_ConnectionError event, Emitter<MitraChatState> emit) {}
void _cleanup() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
_typingTimer?.cancel();
_typingTimer = null;
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart';
class MitraChatHistoryScreen extends StatefulWidget {
const MitraChatHistoryScreen({super.key});
@override
State<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
}
class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
try {
final api = context.read<ApiClient>();
final response = await api.get('/api/mitra/chat-requests/history');
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
setState(() {
_sessions = items;
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Riwayat Chat')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty
? const Center(child: Text('Belum ada riwayat chat'))
: ListView.builder(
itemCount: _sessions.length,
itemBuilder: (context, index) {
final s = _sessions[index];
final customerName = s['customer_display_name'] as String? ?? 'Customer';
final endedAt = s['ended_at'] != null
? DateTime.parse(s['ended_at'] as String).toLocal()
: null;
final duration = s['duration_minutes'] as int?;
final closureMsg = s['mitra_closure_message'] as String?;
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: Text(customerName),
subtitle: Text([
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
if (duration != null) '$duration menit',
if (closureMsg != null) '"$closureMsg"',
].join(' - ')),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/chat/history/${s['id']}'),
);
},
),
);
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_client.dart';
class MitraChatTranscriptScreen extends StatefulWidget {
final String sessionId;
const MitraChatTranscriptScreen({super.key, required this.sessionId});
@override
State<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
}
class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
List<Map<String, dynamic>> _messages = [];
List<Map<String, dynamic>> _closures = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadTranscript();
}
Future<void> _loadTranscript() async {
try {
final api = context.read<ApiClient>();
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
final data = response['data'] as Map<String, dynamic>;
setState(() {
_messages = (data['messages'] as List<dynamic>).cast<Map<String, dynamic>>();
_closures = (data['closures'] as List<dynamic>).cast<Map<String, dynamic>>();
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Transkrip Chat')),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
..._messages.map((m) {
final isMe = m['sender_type'] == 'mitra';
final time = DateTime.parse(m['created_at'] as String).toLocal();
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
decoration: BoxDecoration(
color: isMe ? Colors.green.shade100 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(m['content'] as String, style: const TextStyle(fontSize: 15)),
const SizedBox(height: 4),
Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
),
);
}),
if (_closures.isNotEmpty) ...[
const Divider(height: 32),
const Text('Pesan Penutup', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
..._closures.map((c) => Card(
child: ListTile(
title: Text(c['user_type'] == 'mitra' ? 'Kamu' : 'Customer'),
subtitle: Text(c['message'] as String),
),
)),
],
],
),
);
}
}

View File

@@ -0,0 +1,337 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/chat/mitra_chat_bloc.dart';
import '../../../core/chat/extension_bloc.dart';
class MitraChatScreen extends StatefulWidget {
final String sessionId;
final String customerName;
const MitraChatScreen({super.key, required this.sessionId, required this.customerName});
@override
State<MitraChatScreen> createState() => _MitraChatScreenState();
}
class _MitraChatScreenState extends State<MitraChatScreen> {
final _messageController = TextEditingController();
final _scrollController = ScrollController();
Timer? _typingThrottle;
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
void _onTextChanged(String text) {
if (_typingThrottle?.isActive ?? false) return;
context.read<MitraChatBloc>().add(SendTyping());
_typingThrottle = Timer(const Duration(seconds: 2), () {});
}
void _sendMessage() {
final text = _messageController.text.trim();
if (text.isEmpty) return;
context.read<MitraChatBloc>().add(SendMessage(text));
_messageController.clear();
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<MitraChatBloc, MitraChatState>(
listener: (context, state) {
if (state is ChatConnected) {
_scrollToBottom();
final unread = state.messages
.where((m) => m.senderType == 'customer' && m.status != 'read')
.map((m) => m.id)
.toList();
if (unread.isNotEmpty) {
context.read<MitraChatBloc>().add(MarkMessagesRead(unread));
}
if (state.sessionClosing) {
// Trigger goodbye view
}
}
},
),
BlocListener<ExtensionBloc, ExtensionState>(
listener: (context, state) {
if (state is ExtensionComplete) {
context.go('/home');
}
},
),
],
child: Scaffold(
appBar: AppBar(
title: Text(widget.customerName),
actions: [
BlocBuilder<MitraChatBloc, MitraChatState>(
builder: (context, state) {
if (state is ChatConnected && state.remainingSeconds != null) {
return Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text(
'${state.remainingSeconds}s',
style: TextStyle(
color: state.remainingSeconds! < 30 ? Colors.red : null,
fontWeight: FontWeight.bold,
),
),
),
);
}
return const SizedBox.shrink();
},
),
],
),
body: BlocBuilder<MitraChatBloc, MitraChatState>(
builder: (context, state) {
if (state is ChatConnecting) {
return const Center(child: CircularProgressIndicator());
}
if (state is ChatError) {
return Center(child: Text(state.message));
}
if (state is ChatConnected) {
return _buildChatBody(context, state);
}
return const SizedBox.shrink();
},
),
),
);
}
Widget _buildChatBody(BuildContext context, ChatConnected state) {
// Extension request from customer
if (state.extensionRequest != null) {
return _buildExtensionView(context, state.extensionRequest!);
}
// Goodbye view
final extState = context.watch<ExtensionBloc>().state;
if (state.sessionClosing || extState is ExtensionShowGoodbye || extState is ExtensionSubmitting) {
return _buildGoodbyeView(context, extState);
}
return Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length,
itemBuilder: (context, index) {
final msg = state.messages[index];
final isMe = msg.senderType == 'mitra';
return _buildMessageBubble(msg, isMe);
},
),
),
if (state.isOtherTyping)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
),
),
_buildInputBar(),
],
);
}
Widget _buildMessageBubble(ChatMessage msg, bool isMe) {
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
decoration: BoxDecoration(
color: isMe ? Colors.green.shade100 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(msg.content, style: const TextStyle(fontSize: 15)),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(msg.status),
],
],
),
],
),
),
);
}
Widget _buildStatusIcon(String status) {
switch (status) {
case 'sending':
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
case 'sent':
return const Icon(Icons.check, size: 14, color: Colors.grey);
case 'delivered':
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
case 'read':
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
default:
return const SizedBox.shrink();
}
}
Widget _buildInputBar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
onChanged: _onTextChanged,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
decoration: InputDecoration(
hintText: 'Ketik pesan...',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send, color: Colors.green),
onPressed: _sendMessage,
),
],
),
),
);
}
Widget _buildExtensionView(BuildContext context, Map<String, dynamic> request) {
final duration = request['duration_minutes'] as int?;
final extensionId = request['extension_id'] as String?;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.timer, size: 64, color: Colors.orange),
const SizedBox(height: 16),
const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
sessionId: widget.sessionId,
extensionId: extensionId!,
accepted: true,
)),
child: const Text('Terima', style: TextStyle(color: Colors.white)),
),
const SizedBox(width: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
sessionId: widget.sessionId,
extensionId: extensionId!,
accepted: false,
)),
child: const Text('Tolak', style: TextStyle(color: Colors.white)),
),
],
),
],
),
),
);
}
Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) {
final controller = TextEditingController();
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
const SizedBox(height: 16),
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
const SizedBox(height: 24),
TextField(
controller: controller,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Terima kasih sudah curhat...',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: extState is ExtensionSubmitting
? null
: () {
final text = controller.text.trim();
if (text.isNotEmpty) {
context.read<ExtensionBloc>().add(
SubmitGoodbye(sessionId: widget.sessionId, message: text),
);
}
},
child: extState is ExtensionSubmitting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Kirim & Selesai'),
),
],
),
),
);
}
}

View File

@@ -170,13 +170,25 @@ class _StatusToggle extends StatelessWidget {
class _ActiveSessionsButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: const Text('Sesi Aktif'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).pushNamed('/sessions'),
),
return Column(
children: [
Card(
child: ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: const Text('Sesi Aktif'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).pushNamed('/sessions'),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.history),
title: const Text('Riwayat Chat'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).pushNamed('/chat/history'),
),
),
],
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -6,12 +7,18 @@ import 'core/api/api_client.dart';
import 'core/auth/auth_bloc.dart';
import 'core/status/status_bloc.dart';
import 'core/chat/chat_request_bloc.dart';
import 'core/chat/mitra_chat_bloc.dart';
import 'core/chat/extension_bloc.dart';
import 'firebase_options.dart';
import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
runApp(const App());
}
@@ -38,6 +45,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_router = buildRouter(_authBloc);
_statusBloc = StatusBloc(apiClient: _apiClient);
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
_registerFcmToken();
}
Future<void> _registerFcmToken() {
return _authBloc.stream.where((s) => s is AuthAuthenticated).first.then((_) async {
try {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await _apiClient.post('/api/shared/device-token', data: {'token': token});
}
} catch (_) {}
});
}
@override
@@ -66,6 +85,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
BlocProvider.value(value: _authBloc),
BlocProvider.value(value: _statusBloc),
BlocProvider.value(value: _chatRequestBloc),
BlocProvider(create: (_) => MitraChatBloc(apiClient: _apiClient)),
BlocProvider(create: (_) => ExtensionBloc(apiClient: _apiClient)),
RepositoryProvider.value(value: _apiClient),
],
child: BlocListener<AuthBloc, AuthState>(

View File

@@ -6,6 +6,9 @@ import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/home/home_screen.dart';
import 'features/chat/screens/active_sessions_screen.dart';
import 'features/chat/screens/mitra_chat_screen.dart';
import 'features/chat/screens/chat_history_screen.dart';
import 'features/chat/screens/chat_transcript_screen.dart';
class _BlocRefreshNotifier extends ChangeNotifier {
late final StreamSubscription _subscription;
@@ -40,6 +43,17 @@ GoRouter buildRouter(AuthBloc authBloc) {
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()),
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
return MitraChatScreen(
sessionId: state.pathParameters['sessionId']!,
customerName: extra?['customerName'] as String? ?? 'Customer',
);
}),
GoRoute(path: '/chat/history', builder: (_, __) => const MitraChatHistoryScreen()),
GoRoute(path: '/chat/history/:sessionId', builder: (context, state) {
return MitraChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!);
}),
],
);
}