Phase 3 testing fixes: Fastify 5, SSE→WebSocket+FCM, enums, security, session lifecycle
- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6) - Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status) - Add flutter_local_notifications for foreground push notifications with sound - Add splash screen to both apps (hide auth loading flash) - Introduce constants/enums across entire codebase (no raw string literals) - Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier) - Add session ownership validation on all shared chat routes - Add ownership checks on endSession, respondToExtension, requestExtension - Fix session timer: auto-complete expired/stale sessions on server restart - Add 5-min grace period for abandoned closing sessions - Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup - Fix chat screens: ConnectChat in initState, session status check on connect - Fix customer expired view: 5-min countdown, closure state priority over expired state - Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error - Fix GoRouter navigation consistency (no more Navigator.pushNamed) - Fix goodbye view keyboard overflow (SingleChildScrollView) - Add active session card on customer home screen with refresh on navigate back - Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing) - Send session_resumed to both parties on extension accept Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,11 @@ 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 {
|
||||
@@ -21,6 +24,8 @@ class _RequestReceived extends ChatRequestEvent {
|
||||
List<Object?> get props => [data];
|
||||
}
|
||||
|
||||
class _ConnectionError extends ChatRequestEvent {}
|
||||
|
||||
class AcceptRequest extends ChatRequestEvent {
|
||||
final String sessionId;
|
||||
AcceptRequest(this.sessionId);
|
||||
@@ -70,49 +75,76 @@ class ChatRequestError extends ChatRequestState {
|
||||
// Bloc
|
||||
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
||||
final ApiClient apiClient;
|
||||
StreamSubscription? _sseSubscription;
|
||||
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 {
|
||||
_stopSSE();
|
||||
_closeWebSocket();
|
||||
emit(ChatRequestListening());
|
||||
_listenToSSE();
|
||||
await _connectWebSocket();
|
||||
}
|
||||
|
||||
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
|
||||
_stopSSE();
|
||||
_closeWebSocket();
|
||||
emit(ChatRequestIdle());
|
||||
}
|
||||
|
||||
void _listenToSSE() {
|
||||
apiClient.getStream('/api/mitra/chat-requests/incoming').then((response) {
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
_sseSubscription = stream
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.where((line) => line.startsWith('data: '))
|
||||
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
|
||||
.listen(
|
||||
(data) => add(_RequestReceived(data)),
|
||||
onError: (_) {},
|
||||
);
|
||||
}).catchError((_) {});
|
||||
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 == 'chat_request') {
|
||||
if (type == WsMessage.chatRequest) {
|
||||
emit(ChatRequestIncoming(data['session_id'] as String));
|
||||
} else if (type == 'chat_request_closed') {
|
||||
} else if (type == WsMessage.chatRequestClosed) {
|
||||
// Request was taken by another mitra or cancelled
|
||||
if (state is ChatRequestIncoming) {
|
||||
emit(ChatRequestListening());
|
||||
@@ -148,14 +180,16 @@ class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
||||
emit(ChatRequestListening());
|
||||
}
|
||||
|
||||
void _stopSSE() {
|
||||
_sseSubscription?.cancel();
|
||||
_sseSubscription = null;
|
||||
void _closeWebSocket() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stopSSE();
|
||||
_closeWebSocket();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../api/api_client.dart';
|
||||
@@ -65,8 +66,14 @@ class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
|
||||
} else {
|
||||
emit(ExtensionIdle());
|
||||
}
|
||||
} catch (e) {
|
||||
emit(ExtensionError('Gagal merespon perpanjangan.'));
|
||||
} 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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 {
|
||||
@@ -86,6 +87,7 @@ class ChatConnected extends MitraChatState {
|
||||
bool? sessionExpired,
|
||||
bool? sessionClosing,
|
||||
Map<String, dynamic>? extensionRequest,
|
||||
bool clearExtensionRequest = false,
|
||||
}) {
|
||||
return ChatConnected(
|
||||
messages: messages ?? this.messages,
|
||||
@@ -93,7 +95,7 @@ class ChatConnected extends MitraChatState {
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||
extensionRequest: extensionRequest ?? this.extensionRequest,
|
||||
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,8 +123,8 @@ class ChatMessage {
|
||||
required this.id,
|
||||
required this.senderType,
|
||||
required this.content,
|
||||
this.type = 'text',
|
||||
this.status = 'sent',
|
||||
this.type = MessageType.text,
|
||||
this.status = MessageStatus.sent,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
@@ -160,14 +162,27 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
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? ?? 'text',
|
||||
status: m['status'] as String? ?? 'sent',
|
||||
type: m['type'] as String? ?? MessageType.text,
|
||||
status: m['status'] as String? ?? MessageStatus.sent,
|
||||
createdAt: DateTime.parse(m['created_at'] as String),
|
||||
)).toList();
|
||||
|
||||
@@ -188,12 +203,15 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
);
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'auth',
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
'session_id': event.sessionId,
|
||||
}));
|
||||
|
||||
emit(ChatConnected(messages: messages));
|
||||
emit(ChatConnected(
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(ChatError('Gagal terhubung ke chat.'));
|
||||
}
|
||||
@@ -211,7 +229,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final msg = ChatMessage(
|
||||
id: tempId,
|
||||
senderType: 'mitra',
|
||||
senderType: UserType.mitra,
|
||||
content: event.content,
|
||||
status: 'sending',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -220,7 +238,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'message',
|
||||
'type': WsMessage.message,
|
||||
'content': event.content,
|
||||
'_temp_id': tempId,
|
||||
}));
|
||||
@@ -228,17 +246,17 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
|
||||
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
||||
}
|
||||
|
||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'delivered', 'message_ids': event.messageIds}));
|
||||
_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': 'read', 'message_ids': event.messageIds}));
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': event.messageIds}));
|
||||
}
|
||||
|
||||
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) {
|
||||
@@ -248,30 +266,30 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
final type = data['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
case 'auth_ok':
|
||||
case WsMessage.authOk:
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
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? ?? 'text',
|
||||
status: 'sent',
|
||||
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 'message_ack':
|
||||
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 == 'mitra');
|
||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
|
||||
if (idx >= 0) {
|
||||
final old = updatedMessages[idx];
|
||||
updatedMessages[idx] = ChatMessage(
|
||||
@@ -286,7 +304,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
break;
|
||||
|
||||
case 'message_status':
|
||||
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) {
|
||||
@@ -296,7 +314,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
case WsMessage.typing:
|
||||
emit(current.copyWith(isOtherTyping: true));
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||
@@ -306,27 +324,27 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
});
|
||||
break;
|
||||
|
||||
case 'session_timer':
|
||||
case WsMessage.sessionTimer:
|
||||
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
|
||||
break;
|
||||
|
||||
case 'session_expired':
|
||||
case WsMessage.sessionExpired:
|
||||
emit(current.copyWith(sessionExpired: true));
|
||||
break;
|
||||
|
||||
case 'extension_request':
|
||||
case WsMessage.extensionRequest:
|
||||
emit(current.copyWith(extensionRequest: data));
|
||||
break;
|
||||
|
||||
case 'session_resumed':
|
||||
emit(current.copyWith(sessionExpired: false, extensionRequest: null));
|
||||
case WsMessage.sessionResumed:
|
||||
emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true));
|
||||
break;
|
||||
|
||||
case 'session_closing':
|
||||
emit(current.copyWith(sessionClosing: true));
|
||||
case WsMessage.sessionClosing:
|
||||
emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true));
|
||||
break;
|
||||
|
||||
case 'session_completed':
|
||||
case WsMessage.sessionCompleted:
|
||||
_cleanup();
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user