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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
83
client_app/lib/core/constants.dart
Normal file
83
client_app/lib/core/constants.dart
Normal 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._();
|
||||
}
|
||||
95
client_app/lib/core/notifications/notification_service.dart
Normal file
95
client_app/lib/core/notifications/notification_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user