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

@@ -32,8 +32,4 @@ class ApiClient {
final response = await _dio.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>;
}
Future<Response> getStream(String path) async {
return _dio.get(path, options: Options(responseType: ResponseType.stream));
}
}

View File

@@ -76,7 +76,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final _auth = FirebaseAuth.instance;
String? _pendingVerificationId;
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
on<AppStarted>(_onAppStarted);
on<AnonymousLoginRequested>(_onAnonymousLogin);
on<GoogleLoginRequested>(_onGoogleLogin);

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 ChatEvent extends Equatable {
@@ -125,8 +126,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,
});
@@ -164,6 +165,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
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',
@@ -173,8 +187,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
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();
@@ -197,12 +211,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// Send auth message
_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.'));
}
@@ -221,7 +238,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = ChatMessage(
id: tempId,
senderType: 'customer',
senderType: UserType.customer,
content: event.content,
status: 'sending',
createdAt: DateTime.now(),
@@ -230,7 +247,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(current.copyWith(messages: [...current.messages, msg]));
_channel!.sink.add(jsonEncode({
'type': 'message',
'type': WsMessage.message,
'content': event.content,
'_temp_id': tempId,
}));
@@ -238,13 +255,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'typing'}));
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
}
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'delivered',
'type': WsMessage.delivered,
'message_ids': event.messageIds,
}));
}
@@ -252,7 +269,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'read',
'type': WsMessage.read,
'message_ids': event.messageIds,
}));
}
@@ -264,17 +281,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final type = data['type'] as String?;
switch (type) {
case 'auth_ok':
case WsMessage.authOk:
// Already connected
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]));
@@ -282,7 +299,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
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) {
@@ -292,7 +309,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return m;
}).toList();
// Replace temp ID with real ID
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'customer');
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
if (idx >= 0) {
final old = updatedMessages[idx];
updatedMessages[idx] = ChatMessage(
@@ -307,7 +324,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
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) {
@@ -319,7 +336,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(current.copyWith(messages: updatedMessages));
break;
case 'typing':
case WsMessage.typing:
emit(current.copyWith(isOtherTyping: true));
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () {
@@ -329,36 +346,41 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
});
break;
case 'session_timer':
case WsMessage.sessionTimer:
final remaining = data['remaining_seconds'] as int?;
emit(current.copyWith(remainingSeconds: remaining));
break;
case 'session_expired':
case WsMessage.sessionExpired:
emit(current.copyWith(sessionExpired: true));
break;
case 'session_paused':
case WsMessage.sessionPaused:
emit(current.copyWith(sessionPaused: true));
break;
case 'session_resumed':
emit(current.copyWith(sessionPaused: false, sessionExpired: false));
case WsMessage.sessionResumed:
emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false));
break;
case 'session_closing':
case WsMessage.sessionClosing:
emit(current.copyWith(sessionClosing: true));
break;
case 'extension_response':
emit(current.copyWith(extensionResponse: data));
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 'session_completed':
case WsMessage.sessionCompleted:
_cleanup();
break;
case 'error':
case WsMessage.error:
// Keep connected but show error
break;
}

View File

@@ -18,6 +18,7 @@ class RequestExtension extends SessionClosureEvent {
}
class DeclineExtension extends SessionClosureEvent {}
class ResetClosure extends SessionClosureEvent {}
class SubmitGoodbye extends SessionClosureEvent {
final String sessionId;
@@ -56,6 +57,7 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
on<RequestExtension>(_onRequestExtension);
on<DeclineExtension>(_onDeclineExtension);
on<ResetClosure>(_onReset);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
@@ -76,6 +78,10 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
emit(ClosureShowGoodbye());
}
void _onReset(ResetClosure event, Emitter<SessionClosureState> emit) {
emit(ClosureInitial());
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
emit(ClosureSubmitting());
try {

View File

@@ -0,0 +1,83 @@
/// User types
class UserType {
static const customer = 'customer';
static const mitra = 'mitra';
UserType._();
}
/// Chat session statuses
class SessionStatus {
static const searching = 'searching';
static const pendingAcceptance = 'pending_acceptance';
static const pendingPayment = 'pending_payment';
static const active = 'active';
static const extending = 'extending';
static const closing = 'closing';
static const completed = 'completed';
static const cancelled = 'cancelled';
static const expired = 'expired';
SessionStatus._();
}
/// Chat message statuses
class MessageStatus {
static const sent = 'sent';
static const delivered = 'delivered';
static const read = 'read';
MessageStatus._();
}
/// Chat message types
class MessageType {
static const text = 'text';
MessageType._();
}
/// Session extension statuses
class ExtensionStatus {
static const pending = 'pending';
static const accepted = 'accepted';
static const rejected = 'rejected';
static const timeout = 'timeout';
ExtensionStatus._();
}
/// WebSocket message types
class WsMessage {
// Auth
static const auth = 'auth';
static const authOk = 'auth_ok';
static const error = 'error';
// Chat
static const message = 'message';
static const messageAck = 'message_ack';
static const messageStatus = 'message_status';
static const typing = 'typing';
// Pairing
static const chatRequest = 'chat_request';
static const chatRequestClosed = 'chat_request_closed';
static const paired = 'paired';
// Session lifecycle
static const sessionTimer = 'session_timer';
static const sessionExpired = 'session_expired';
static const sessionClosing = 'session_closing';
static const sessionCompleted = 'session_completed';
static const sessionPaused = 'session_paused';
static const sessionResumed = 'session_resumed';
// Extension
static const extensionRequest = 'extension_request';
static const extensionResponse = 'extension_response';
// Delivery
static const delivered = 'delivered';
static const read = 'read';
// Early end
static const earlyEnd = 'early_end';
WsMessage._();
}

View File

@@ -0,0 +1,95 @@
import 'dart:convert';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:go_router/go_router.dart';
/// Handles FCM foreground/background notifications and local notification display.
class NotificationService {
static final _localNotifications = FlutterLocalNotificationsPlugin();
static GoRouter? _router;
static const _channel = AndroidNotificationChannel(
'chat_messages',
'Chat Messages',
description: 'Notifications for incoming chat messages',
importance: Importance.high,
);
static Future<void> initialize(GoRouter router) async {
_router = router;
// Create Android notification channel
await _localNotifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(_channel);
// Initialize local notifications
await _localNotifications.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: _onNotificationTap,
);
// FCM foreground messages → show local notification
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
// FCM notification tap (app was in background)
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
// Check if app was opened from a terminated state via notification
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_navigateFromMessage(initialMessage.data);
}
}
static void _onForegroundMessage(RemoteMessage message) {
final notification = message.notification;
if (notification == null) return;
_localNotifications.show(
id: notification.hashCode,
title: notification.title,
body: notification.body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channel.id,
_channel.name,
channelDescription: _channel.description,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: jsonEncode(message.data),
);
}
static void _onNotificationTap(NotificationResponse response) {
if (response.payload == null) return;
try {
final data = jsonDecode(response.payload!) as Map<String, dynamic>;
_navigateFromMessage(data);
} catch (_) {}
}
static void _onMessageOpenedApp(RemoteMessage message) {
_navigateFromMessage(message.data);
}
static void _navigateFromMessage(Map<String, dynamic> data) {
final sessionId = data['session_id'] as String?;
if (sessionId == null || _router == null) return;
final type = data['type'] as String?;
if (type == 'chat_message' || type == 'chat_request') {
_router!.push('/chat/session/$sessionId');
}
}
}

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 PairingEvent extends Equatable {
@@ -32,6 +35,7 @@ class _PairingStatusUpdate extends PairingEvent {
}
class _PairingTimeout extends PairingEvent {}
class _ConnectionError extends PairingEvent {}
// States
abstract class PairingState extends Equatable {
@@ -77,7 +81,8 @@ class PairingError extends PairingState {
class PairingBloc extends Bloc<PairingEvent, PairingState> {
final ApiClient apiClient;
Timer? _timeoutTimer;
StreamSubscription? _sseSubscription;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
on<RequestPairing>(_onRequestPairing);
@@ -85,6 +90,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
on<CancelPairing>(_onCancelPairing);
on<_PairingStatusUpdate>(_onStatusUpdate);
on<_PairingTimeout>(_onTimeout);
on<_ConnectionError>(_onConnectionError);
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
@@ -107,6 +113,9 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
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;
@@ -116,9 +125,8 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
_timeoutTimer = Timer(const Duration(seconds: 60), () {
add(_PairingTimeout());
});
_listenToSSE(sessionId);
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
emit(PairingNoBestie());
@@ -132,26 +140,45 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
}
}
void _listenToSSE(String sessionId) {
apiClient.getStream('/api/client/chat/request/$sessionId/status').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(_PairingStatusUpdate(data)),
onError: (_) {},
);
}).catchError((_) {});
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 == 'paired') {
if (type == WsMessage.paired) {
_cleanup();
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
final sessionId = data['session_id'] as String;
@@ -160,7 +187,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
// Brief delay then transition to active
await Future.delayed(const Duration(seconds: 2));
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
} else if (type == 'expired') {
} else if (type == SessionStatus.expired) {
_cleanup();
emit(PairingNoBestie());
}
@@ -182,11 +209,17 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
emit(PairingNoBestie());
}
void _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_sseSubscription?.cancel();
_sseSubscription = null;
_closeWebSocket();
}
@override