329 lines
12 KiB
Dart
329 lines
12 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:taxglide/services/notification_service.dart';
|
||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:taxglide/controller/api_contoller.dart';
|
||
|
||
class NotificationWebSocket {
|
||
static final NotificationWebSocket _instance =
|
||
NotificationWebSocket._internal();
|
||
factory NotificationWebSocket() => _instance;
|
||
NotificationWebSocket._internal();
|
||
|
||
WebSocketChannel? _channel;
|
||
StreamSubscription? _subscription;
|
||
Timer? _reconnectTimer;
|
||
|
||
bool _isConnected = false;
|
||
bool _isIntentionalClose = false;
|
||
|
||
String? _userId;
|
||
String? _wsUrl;
|
||
ProviderContainer? _container;
|
||
|
||
int _reconnectAttempts = 0;
|
||
static const int _maxReconnectAttempts = 10;
|
||
|
||
/// ChatId the user is currently VIEWING.
|
||
String? _activeChatId;
|
||
|
||
/// ChatId whose `chat.<id>` channel we are subscribed to on this WS.
|
||
String? _subscribedChatChannelId;
|
||
|
||
// ─────────────────────────── Public API ────────────────────────────────────
|
||
|
||
/// Call from LiveChatScreen.initState().
|
||
void setActiveChatId(String chatId) {
|
||
_activeChatId = chatId;
|
||
debugPrint('NotificationWS: viewing chat $chatId');
|
||
_subscribeToChatChannel(chatId);
|
||
}
|
||
|
||
/// Call from LiveChatScreen.dispose()
|
||
void clearActiveChatId() {
|
||
debugPrint('NotificationWS: left chat $_activeChatId');
|
||
_activeChatId = null;
|
||
}
|
||
|
||
/// Subscribe to a chat channel for updates.
|
||
void watchChatChannel(String chatId) {
|
||
debugPrint('Eye NotificationWS: watchChatChannel($chatId)');
|
||
_subscribeToChatChannel(chatId);
|
||
}
|
||
|
||
/// Connect (or skip if already connected for the same user).
|
||
Future<void> connect({
|
||
required String userId,
|
||
required ProviderContainer container,
|
||
String baseUrl = 'wss://taxglide.amrithaa.net:443/reverb',
|
||
String appKey = '2yj0lyzc9ylw2h03ts6i',
|
||
}) async {
|
||
if (_isConnected && _userId == userId) {
|
||
debugPrint('⚡ NotificationWS: already connected for user $userId');
|
||
return;
|
||
}
|
||
|
||
await disconnect();
|
||
|
||
_userId = userId;
|
||
_container = container;
|
||
_wsUrl = '$baseUrl/app/$appKey?protocol=7&client=js&version=1.0';
|
||
_isIntentionalClose = false;
|
||
_reconnectAttempts = 0;
|
||
|
||
_openSocket();
|
||
}
|
||
|
||
// ──────────────────────── Socket lifecycle ──────────────────────────────────
|
||
|
||
void _openSocket() {
|
||
try {
|
||
debugPrint('🔌 NotificationWS connecting…');
|
||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl!));
|
||
|
||
_subscription = _channel!.stream.listen(
|
||
_handleMessage,
|
||
onError: _handleError,
|
||
onDone: _handleDone,
|
||
);
|
||
|
||
_isConnected = true;
|
||
|
||
_subscribeToUserChannel();
|
||
|
||
// Re-subscribe to chat channel after reconnect if we had one before.
|
||
final chatId = _subscribedChatChannelId;
|
||
if (chatId != null) {
|
||
_sendRaw({
|
||
'event': 'pusher:subscribe',
|
||
'data': {'channel': 'chat.$chatId'},
|
||
});
|
||
debugPrint('📡 NotificationWS: re-subscribed to chat.$chatId');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ NotificationWS connect error: $e');
|
||
_isConnected = false;
|
||
_scheduleReconnect();
|
||
}
|
||
}
|
||
|
||
void _subscribeToUserChannel() {
|
||
if (_userId == null) return;
|
||
_sendRaw({
|
||
'event': 'pusher:subscribe',
|
||
'data': {'channel': 'user-chat-notification.$_userId'},
|
||
});
|
||
debugPrint(
|
||
'📡 NotificationWS: subscribed to user-chat-notification.$_userId',
|
||
);
|
||
}
|
||
|
||
void _subscribeToChatChannel(String chatId) {
|
||
if (_subscribedChatChannelId == chatId) return; // already subscribed
|
||
|
||
// Unsubscribe previous chat channel
|
||
if (_subscribedChatChannelId != null) {
|
||
_sendRaw({
|
||
'event': 'pusher:unsubscribe',
|
||
'data': {'channel': 'chat.$_subscribedChatChannelId'},
|
||
});
|
||
debugPrint(
|
||
'📡 NotificationWS: unsubscribed from chat.$_subscribedChatChannelId',
|
||
);
|
||
}
|
||
|
||
_subscribedChatChannelId = chatId;
|
||
_sendRaw({
|
||
'event': 'pusher:subscribe',
|
||
'data': {'channel': 'chat.$chatId'},
|
||
});
|
||
debugPrint('📡 NotificationWS: subscribed to chat.$chatId');
|
||
}
|
||
|
||
// ──────────────────────── Message handler ──────────────────────────────────
|
||
|
||
void _handleMessage(dynamic raw) {
|
||
debugPrint('🔔 NotificationWS raw: $raw');
|
||
|
||
try {
|
||
final msg = jsonDecode(raw) as Map<String, dynamic>;
|
||
final event = msg['event']?.toString() ?? '';
|
||
|
||
// ── Pusher connection established → re-subscribe ──────────────────────
|
||
if (event == 'pusher:connection_established') {
|
||
debugPrint('🔗 NotificationWS: connection established');
|
||
_subscribeToUserChannel();
|
||
final chatId = _subscribedChatChannelId;
|
||
if (chatId != null) {
|
||
_sendRaw({
|
||
'event': 'pusher:subscribe',
|
||
'data': {'channel': 'chat.$chatId'},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── Skip all other Pusher internal events ─────────────────────────────
|
||
if (event.startsWith('pusher')) return;
|
||
if (event.startsWith('pusher_internal')) return;
|
||
|
||
// ── Refresh Riverpod badge count ──────────────────────────────────────
|
||
try {
|
||
_container?.read(notificationTriggerProvider.notifier).state++;
|
||
} catch (_) {}
|
||
|
||
// ── Determine which chat this message belongs to ──────────────────────
|
||
String? incomingChatId;
|
||
final channelStr = msg['channel']?.toString() ?? '';
|
||
if (channelStr.startsWith('chat.')) {
|
||
incomingChatId = channelStr.split('.').last;
|
||
}
|
||
|
||
// ── Parse the payload ─────────────────────────────────────────────────
|
||
final rawData = msg['data'];
|
||
if (rawData == null) return;
|
||
|
||
dynamic data;
|
||
try {
|
||
data = rawData is String ? jsonDecode(rawData) : rawData;
|
||
} catch (_) {
|
||
data = rawData;
|
||
}
|
||
|
||
// Flatten list → use first element
|
||
if (data is List && data.isNotEmpty) {
|
||
data = data[0];
|
||
}
|
||
|
||
if (data is! Map) {
|
||
debugPrint('⚠️ NotificationWS: unrecognised data format, skipping');
|
||
return;
|
||
}
|
||
|
||
final dataMap = Map<String, dynamic>.from(data);
|
||
debugPrint('🔔 NotificationWS data keys: ${dataMap.keys.toList()}');
|
||
|
||
// ── Skip ping-only payloads {status, reload} ──────────────────────────
|
||
// The backend first fires a lightweight notification-trigger event that
|
||
// only contains `status` and `reload`. There is nothing to display yet;
|
||
// the full message list arrives in the next event on the same channel.
|
||
final onlyStatusReload =
|
||
dataMap.containsKey('status') &&
|
||
dataMap.containsKey('reload') &&
|
||
!dataMap.containsKey('message') &&
|
||
!dataMap.containsKey('chat_by');
|
||
if (onlyStatusReload) {
|
||
debugPrint('🔔 NotificationWS: reload ping received, badge refreshed');
|
||
return;
|
||
}
|
||
|
||
// ── Skip user's own messages ──────────────────────────────────────────
|
||
final chatBy = dataMap['chat_by']?.toString().toLowerCase() ?? '';
|
||
if (chatBy == 'user' || chatBy == 'customer') {
|
||
debugPrint('🔕 NotificationWS: own message, skipping');
|
||
return;
|
||
}
|
||
|
||
// ── Extract routing info if needed for internal state ─────────────
|
||
final String? page =
|
||
dataMap['page']?.toString() ?? dataMap['type']?.toString();
|
||
final String? pageId =
|
||
dataMap['page_id']?.toString() ?? dataMap['pageId']?.toString();
|
||
|
||
if (page == null || page.isEmpty || pageId == null || pageId.isEmpty) {
|
||
// Chat Notification fallback
|
||
incomingChatId ??=
|
||
dataMap['chat_id']?.toString() ??
|
||
dataMap['chatId']?.toString() ??
|
||
(dataMap['parent_tag'] != null && dataMap['parent_tag'] is Map
|
||
? dataMap['parent_tag']['id']?.toString()
|
||
: null) ??
|
||
dataMap['id']?.toString();
|
||
|
||
if (incomingChatId != null && incomingChatId != '0') {
|
||
_subscribeToChatChannel(incomingChatId);
|
||
}
|
||
}
|
||
|
||
// ── Show Foreground Notification ─────────────────────────────────────
|
||
// Only show if it's NOT a message for the currently active chat screen.
|
||
// Pass incomingChatId as the dedup tag so that if FCM already fired a
|
||
// notification for this same chat, this one *replaces* it on Android
|
||
// instead of showing a second notification.
|
||
if (incomingChatId != _activeChatId) {
|
||
// Prefer the sender name from dataMap for the title.
|
||
final String senderTitle =
|
||
dataMap['sender_name']?.toString() ??
|
||
dataMap['name']?.toString() ??
|
||
dataMap['chat_by']?.toString() ??
|
||
'New Message';
|
||
|
||
final RemoteMessage fcmLikeMessage = RemoteMessage(
|
||
data: dataMap,
|
||
notification: RemoteNotification(
|
||
title: senderTitle,
|
||
body: dataMap['message']?.toString() ?? 'You have a new message',
|
||
),
|
||
);
|
||
|
||
Get.find<NotificationService>().showForegroundNotification(
|
||
fcmLikeMessage,
|
||
tag: incomingChatId, // dedup key – same chat collapses into 1 notif
|
||
);
|
||
}
|
||
} catch (e, st) {
|
||
debugPrint('❌ NotificationWS _handleMessage error: $e\n$st');
|
||
}
|
||
}
|
||
|
||
void _sendRaw(Map<String, dynamic> message) {
|
||
try {
|
||
_channel?.sink.add(jsonEncode(message));
|
||
} catch (e) {
|
||
debugPrint('❌ NotificationWS send error: $e');
|
||
}
|
||
}
|
||
|
||
// ──────────────────────── Reconnect ────────────────────────────────────────
|
||
|
||
void _handleError(dynamic error) {
|
||
debugPrint('❌ NotificationWS error: $error');
|
||
_isConnected = false;
|
||
_scheduleReconnect();
|
||
}
|
||
|
||
void _handleDone() {
|
||
debugPrint('🔌 NotificationWS: connection closed');
|
||
_isConnected = false;
|
||
if (!_isIntentionalClose) _scheduleReconnect();
|
||
}
|
||
|
||
void _scheduleReconnect() {
|
||
if (_isIntentionalClose || _reconnectAttempts >= _maxReconnectAttempts) {
|
||
return;
|
||
}
|
||
_reconnectAttempts++;
|
||
final delay = Duration(seconds: _reconnectAttempts * 2);
|
||
debugPrint(
|
||
'🔄 NotificationWS: reconnect #$_reconnectAttempts in ${delay.inSeconds}s',
|
||
);
|
||
_reconnectTimer = Timer(delay, () {
|
||
if (!_isIntentionalClose) _openSocket();
|
||
});
|
||
}
|
||
|
||
Future<void> disconnect() async {
|
||
_isIntentionalClose = true;
|
||
_reconnectTimer?.cancel();
|
||
await _subscription?.cancel();
|
||
await _channel?.sink.close();
|
||
_isConnected = false;
|
||
_subscribedChatChannelId = null;
|
||
debugPrint('🔌 NotificationWS: disconnected');
|
||
}
|
||
}
|