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:
2026-04-09 00:17:25 +08:00
parent b4efcf14c2
commit b0502ac92b
58 changed files with 2148 additions and 709 deletions

View File

@@ -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();
}
}

View File

@@ -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.'));
}
}
}

View File

@@ -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;
}