From deb8748fc18ea79d5039ea3f4c06853f5b4664be Mon Sep 17 00:00:00 2001 From: Sikander Saleem Date: Mon, 7 Sep 2026 09:28:54 +0300 Subject: [PATCH] improvements --- .../chat/calling/audio_call_page.dart | 121 ++++----- .../cx_module/chat/calling/call_provider.dart | 249 +++++++++++++----- lib/modules/cx_module/chat/chat_provider.dart | 15 +- lib/modules/cx_module/chat/chat_widget.dart | 212 +++++++++------ 4 files changed, 389 insertions(+), 208 deletions(-) diff --git a/lib/modules/cx_module/chat/calling/audio_call_page.dart b/lib/modules/cx_module/chat/calling/audio_call_page.dart index 34c76582..8373f1ab 100644 --- a/lib/modules/cx_module/chat/calling/audio_call_page.dart +++ b/lib/modules/cx_module/chat/calling/audio_call_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; @@ -13,16 +12,16 @@ import '../chat_provider.dart'; import 'call_provider.dart'; class AudioCallPage extends StatefulWidget { - Participants sender; - Participants recipient; - int moduleId; - int? referenceID; - bool isAccept; + final Participants sender; + final Participants recipient; + final int moduleId; + final int? referenceID; + final bool isAccept; - AudioCallPage({Key? key, required this.sender, required this.recipient, this.isAccept = false, required this.moduleId, this.referenceID}) : super(key: key); + const AudioCallPage({Key? key, required this.sender, required this.recipient, this.isAccept = false, required this.moduleId, this.referenceID}) : super(key: key); @override - _AudioCallPageState createState() { + State createState() { return _AudioCallPageState(); } } @@ -45,79 +44,67 @@ class _AudioCallPageState extends State { } void makeCall() async { - bool isConnected = await callPro.buildHubConnection(widget.sender.employeeNumber!); + bool isConnected = false; + try { + isConnected = await callPro.buildHubConnection(widget.sender.employeeNumber ?? "", hubC: chatHubConnection); + } catch (e) { + debugPrint("⚠️ Error preparing call connection: $e"); + isConnected = false; + } + + if (!mounted) return; if (!isConnected) { - context.showConfirmDialog("Error while connecting to call service. Please try again later.", title: "Connection Error", onTap: () { - Navigator.pop(context); - Navigator.pop(context); - }); + _showFailure("Error while connecting to call service. Please try again later.", title: "Connection Error"); return; } + callPro.initAudioCallListeners(); if (widget.isAccept) { - callPro.acceptCall(widget.sender.employeeNumber ?? "", widget.recipient?.employeeNumber ?? "", false, moduleId: widget.moduleId); + callPro.acceptCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", false, moduleId: widget.moduleId); } else { - callPro.startCall(widget.sender.employeeNumber ?? "", widget.recipient?.employeeNumber ?? "", false, widget.moduleId, widget.referenceID, onSuccess: (String msg) { - callPro.dispose(); - Navigator.pop(context); + callPro.startCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", false, widget.moduleId, widget.referenceID, onSuccess: (String msg) { + _closeCall(); }, onFailed: (String msg) { - callPro.dispose(); - print("isDialogOpen:$isDialogOpen"); - if (isDialogOpen) { - return; - } - isDialogOpen = true; - showDialog( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (BuildContext cxt) => PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, Object? result) { - if (didPop) return; - }, - child: SingleBtnDialog( - title: "Failed", - message: msg, - okTitle: "Go Back", - onTap: () { - Navigator.pop(context); - Navigator.pop(context); - }), - ), - ).then((val) { - isDialogOpen = false; - }); + _showFailure(msg, title: "Failed"); }); } + } + + void _closeCall() { + callPro.endSession(); + if (mounted) Navigator.pop(context); + } - Map json = { - // "callerID": AppState().chatDetails!.response!.id!.toString(), - // "callerDetails": AppState().chatDetails!.toJson(), - // "receiverID": params!.chatUser!.id.toString(), - // "receiverDetails": params!.chatUser!.toJson(), - // "title": params!.chatUser!.userName!.replaceAll(".", " "), - // "calltype": widget.isAudio ? "Audio" : "Video", - }; - - // CallDataModel callData = CallDataModel.fromJson(json); - // await Navigator.push( - // context, - // MaterialPageRoute( - // builder: (BuildContext context) => OutGoingCall( - // isVideoCall: callType == "VIDEO" ? true : false, - // outGoingCallData: callData, - // ), - // ), - // ).then((value) { - // print("then"); - // callPro.stopListeners(); - // }); + void _showFailure(String message, {required String title}) { + callPro.endSession(); + if (!mounted || isDialogOpen) return; + isDialogOpen = true; + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (BuildContext cxt) => PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) return; + }, + child: SingleBtnDialog( + title: title, + message: message, + okTitle: "Go Back", + onTap: () { + Navigator.pop(cxt); // close dialog + if (mounted) Navigator.pop(context); // close call page + }), + ), + ).then((_) { + isDialogOpen = false; + }); } @override void dispose() { - callPro.stopListeners(); + callPro.endSession(); super.dispose(); } diff --git a/lib/modules/cx_module/chat/calling/call_provider.dart b/lib/modules/cx_module/chat/calling/call_provider.dart index f8b2369c..32a1b36f 100644 --- a/lib/modules/cx_module/chat/calling/call_provider.dart +++ b/lib/modules/cx_module/chat/calling/call_provider.dart @@ -38,17 +38,37 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { String currentStatus = "Connecting..."; + bool _ownsConnection = false; + + void _setStatus(String status) { + if (currentStatus == status) return; + currentStatus = status; + notifyListeners(); + } + Future _disposeConnection() async { + final HubConnection? connection = callHubConnection; + final bool owns = _ownsConnection; + callHubConnection = null; + _ownsConnection = false; + if (connection == null || !owns) return; try { - if (callHubConnection != null) { - await callHubConnection!.stop(); - } + await connection.stop(); } catch (e) { - } finally { - callHubConnection = null; + if (kDebugMode) { + print('⚠️ Error closing call SignalR connection: $e'); + } } - // isLoading = false; - // notifyListeners(); + } + + Future endSession() async { + stopListeners(); + onFailed = null; + onSuccess = null; + isMuted = false; + isPeerMuted = false; + currentStatus = "Connecting..."; + await _disposeConnection(); } @override @@ -73,7 +93,6 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { final HttpConnectionOptions httpOp = HttpConnectionOptions( transport: HttpTransportType.LongPolling, skipNegotiation: false, - // Keep false: signalr_netcore 1.4.4 crashes when logging + LongPolling. logMessageContent: false, accessTokenFactory: () async => token, requestTimeout: 30000, @@ -102,61 +121,115 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { } Future buildHubConnection(String employeeNumber, {HubConnection? hubC}) async { - if (hubC != null) { - callHubConnection = hubC; - await callHubConnection!.start(); - return true; - } + _setStatus("Connecting..."); try { + if (hubC != null) { + final bool reused = await _adoptExistingConnection(hubC); + if (reused) return true; + if (kDebugMode) { + print('ℹ️ Shared chat hub not usable (state: ${hubC.state}), opening a dedicated call connection.'); + } + } await _disposeConnection(); - http.Response response = await ApiClient().postJsonForResponse(URLs.chatSdkToken, {"apiKey": URLs.chatApiKey, "employeeNumber": employeeNumber, "voIPToken": null}); - if (response.statusCode == 200) { - ChatLoginResponse chatResponse = ChatLoginResponse.fromJson(jsonDecode(response.body)); - callHubConnection = await getHubConnection(chatResponse.token!); - await callHubConnection!.start(); - return true; + + final http.Response response = await ApiClient().postJsonForResponse( + URLs.chatSdkToken, + {"apiKey": URLs.chatApiKey, "employeeNumber": employeeNumber, "voIPToken": null}, + ); + if (response.statusCode != 200) { + if (kDebugMode) { + print('⚠️ Call token request failed: HTTP ${response.statusCode}'); + } + return false; } - return false; + final ChatLoginResponse chatResponse = ChatLoginResponse.fromJson(jsonDecode(response.body)); + if (chatResponse.token == null || chatResponse.token!.isEmpty) { + if (kDebugMode) { + print('⚠️ Call token request returned an empty token.'); + } + return false; + } + + callHubConnection = await getHubConnection(chatResponse.token!); + _ownsConnection = true; + await callHubConnection!.start(); + return callHubConnection?.state == HubConnectionState.Connected; } catch (e) { + if (kDebugMode) { + print('⚠️ Error building call SignalR connection: $e'); + } await _disposeConnection(); return false; } } + Future _adoptExistingConnection(HubConnection hubC) async { + switch (hubC.state) { + case HubConnectionState.Connected: + callHubConnection = hubC; + _ownsConnection = false; + return true; + case HubConnectionState.Connecting: + case HubConnectionState.Reconnecting: + final bool connected = await _waitUntilConnected(hubC); + if (connected) { + callHubConnection = hubC; + _ownsConnection = false; + } + return connected; + case HubConnectionState.Disconnected: + // Chat side dropped it - we can legally restart it, but we do not own it + // so we must not stop it when the call ends. + try { + await hubC.start(); + if (hubC.state == HubConnectionState.Connected) { + callHubConnection = hubC; + _ownsConnection = false; + return true; + } + } catch (e) { + if (kDebugMode) { + print('⚠️ Could not restart shared chat hub for call: $e'); + } + } + return false; + default: + // Disconnecting (or unknown) - do not touch it, build our own instead. + return false; + } + } + + Future _waitUntilConnected(HubConnection hub, {Duration timeout = const Duration(seconds: 15)}) async { + final DateTime deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final HubConnectionState? state = hub.state; + if (state == HubConnectionState.Connected) return true; + if (state == HubConnectionState.Disconnected || state == HubConnectionState.Disconnecting) return false; + await Future.delayed(const Duration(milliseconds: 200)); + } + return hub.state == HubConnectionState.Connected; + } + + bool get isHubConnected => callHubConnection?.state == HubConnectionState.Connected; + void initAudioCallListeners() { - callHubConnection!.on("OnCallAcceptedAsync", onCallAcceptedAsync); - callHubConnection!.on("OnIceCandidateAsync", onIceCandidateAsync); - callHubConnection!.on("OnOfferAsync", onOfferAsync); - callHubConnection!.on("OnAnswerOffer", onAnswerOffer); - callHubConnection!.on("OnHangUpAsync", onHangUpAsync); - callHubConnection!.on("OnCallDeclinedAsync", onCallDeclinedAsync); - callHubConnection!.on('OnAudioToggle', onAudioToggle); + final HubConnection? hub = callHubConnection; + if (hub == null) return; + hub.on("OnCallAcceptedAsync", onCallAcceptedAsync); + hub.on("OnIceCandidateAsync", onIceCandidateAsync); + hub.on("OnOfferAsync", onOfferAsync); + hub.on("OnAnswerOffer", onAnswerOffer); + hub.on("OnHangUpAsync", onHangUpAsync); + hub.on("OnCallDeclinedAsync", onCallDeclinedAsync); + hub.on('OnAudioToggle', onAudioToggle); } void initCallListeners() { - callHubConnection!.on("OnCallAcceptedAsync", onCallAcceptedAsync); - callHubConnection!.on("OnIceCandidateAsync", onIceCandidateAsync); - callHubConnection!.on("OnOfferAsync", onOfferAsync); - callHubConnection!.on("OnAnswerOffer", onAnswerOffer); - callHubConnection!.on("OnHangUpAsync", onHangUpAsync); - callHubConnection!.on("OnCallDeclinedAsync", onCallDeclinedAsync); - callHubConnection!.on('OnAudioToggle', onAudioToggle); - callHubConnection!.on('OnCameraToggle', onCameraToggle); - - // callHubConnection!.on('OnIncomingCallAsync', onHandleIncomingCall); - // callHubConnection!.on('OnCallAcceptedAsync', _handleCallAccepted); - // callHubConnection!.on('OnCallDeclinedAsync', _handleCallDeclined); - // callHubConnection!.on('OnHangUpAsync', onHangUpAsync); - // callHubConnection!.on('OnOfferAsync', _handleOffer); - // callHubConnection!.on('OnAnswerOfferAsync', onAnswerOffer); - // callHubConnection!.on('OnIceCandidateAsync', _handleIceCandidate); - // callHubConnection!.on('OnAudioToggle', _handleAudioToggle); - // callHubConnection!.on('OnCameraToggle', _handleCameraToggle); - // callHubConnection!.on('OnUserOnlineAsync', _handleUserOnline); - // callHubConnection!.on('OnUserOfflineAsync', _handleUserOffline); - // callHubConnection!.on('OnCallHistoryUpdated', _handleCallHistoryUpdated); - // callHubConnection!.on('OnError', _handleError); + final HubConnection? hub = callHubConnection; + if (hub == null) return; + initAudioCallListeners(); + hub.on('OnCameraToggle', onCameraToggle); } //Video Constraints @@ -189,11 +262,17 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { }; Future _createPeerConnection() async { - // {"url": "stun:stun.l.google.com:19302"}, + Map configuration = { "iceServers": [ {"urls": 'stun:15.185.116.59:3478'}, - {"urls": "turn:15.185.116.59:3479", "username": "admin", "credential": "admin"} + { + "urls": "turn:15.185.116.59:3479", + "username": "admin", + "credential": "admin", + }, + {"urls": "stun:stun.l.google.com:19302"}, + {"urls": "stun:stun1.l.google.com:19302"}, ] }; @@ -269,25 +348,67 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { this.referenceID = referenceID; this.onFailed = onFailed; this.onSuccess = onSuccess; - invoke(invokeMethod: "CallUserAsync", currentUserID: senderN!, targetUserID: recipientN!, isVideoCall: isVideo!, data: [this.moduleId, referenceID]); + if (senderN == null || senderN.isEmpty || recipientN == null || recipientN.isEmpty) { + onFailed?.call("Call participants could not be resolved. Please reopen the chat and try again."); + return; + } + try { + await invoke(invokeMethod: "CallUserAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo ?? false, data: [this.moduleId, referenceID]); + _setStatus("Ringing..."); + } catch (e) { + if (kDebugMode) { + print('⚠️ CallUserAsync failed: $e'); + } + _setStatus("Call failed"); + onFailed?.call("Could not reach the call service. Please check your connection and try again."); + } } void acceptCall(String senderN, String recipientN, bool isVideo, {required int moduleId}) async { - invoke(invokeMethod: "AnswerCallAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo, data: [this.moduleId]); + this.senderN = senderN; + this.recipientN = recipientN; + try { + await invoke(invokeMethod: "AnswerCallAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo, data: [this.moduleId]); + } catch (e) { + if (kDebugMode) { + print('⚠️ AnswerCallAsync failed: $e'); + } + _setStatus("Call failed"); + onFailed?.call("Could not join the call. Please try again."); + } } Future declineCall(String senderN, String recipientN, {required int moduleId}) async { - await invoke(invokeMethod: "CallDeclinedAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: false, data: [this.moduleId]); + try { + await invoke(invokeMethod: "CallDeclinedAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: false, data: [this.moduleId]); + } catch (e) { + if (kDebugMode) { + print('⚠️ CallDeclinedAsync failed: $e'); + } + } } - void endCall(String senderN, String recipientN, {required int moduleId}) { - invoke(invokeMethod: "HangUpAsync", currentUserID: senderN, targetUserID: recipientN, data: [moduleId]); + void endCall(String senderN, String recipientN, {required int moduleId}) async { + try { + await invoke(invokeMethod: "HangUpAsync", currentUserID: senderN, targetUserID: recipientN, data: [moduleId]); + } catch (e) { + if (kDebugMode) { + print('⚠️ HangUpAsync failed: $e'); + } + } } void toggleMute() async { - await invoke(invokeMethod: "AudioToggle", currentUserID: senderN!, targetUserID: recipientN!); - isMuted = !isMuted; - notifyListeners(); + if (senderN == null || recipientN == null) return; + try { + await invoke(invokeMethod: "AudioToggle", currentUserID: senderN!, targetUserID: recipientN!); + isMuted = !isMuted; + notifyListeners(); + } catch (e) { + if (kDebugMode) { + print('⚠️ AudioToggle failed: $e'); + } + } } void checkCall(Map message) { @@ -317,6 +438,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { void onCallAcceptedAsync(List? params) { print("onCallAcceptedAsync: $params"); + _setStatus("Connected"); } void onIceCandidateAsync(List? params) { @@ -396,8 +518,12 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { } else if (invokeMethod == "AnswerOfferAsync") { args = [targetUserID, data]; } - print("Invoking:$invokeMethod:${args}"); - await callHubConnection?.invoke(invokeMethod, args: args); + print("Invoking:$invokeMethod:$args"); + final HubConnection? hub = callHubConnection; + if (hub == null || hub.state != HubConnectionState.Connected) { + throw StateError("Call hub is not connected (state: ${hub?.state}). Cannot invoke $invokeMethod."); + } + await hub.invoke(invokeMethod, args: args); } void stopListeners() async { @@ -408,6 +534,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { callHubConnection?.off('OnOfferAsync'); callHubConnection?.off('OnHangUpAsync'); callHubConnection?.off('OnAudioToggle'); + callHubConnection?.off('OnCameraToggle'); } handleResponse(List? params) { diff --git a/lib/modules/cx_module/chat/chat_provider.dart b/lib/modules/cx_module/chat/chat_provider.dart index 96886573..4f6a010b 100644 --- a/lib/modules/cx_module/chat/chat_provider.dart +++ b/lib/modules/cx_module/chat/chat_provider.dart @@ -88,6 +88,10 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { late int moduleID; int? referenceID; + String? sessionKey; + + static String buildSessionKey(int moduleId, int requestId) => '$moduleId-$requestId'; + /// OPTIMIZATION: Improved connection disposal to prevent memory leaks /// This properly handles errors and ensures connection is always cleaned up Future _disposeConnection() async { @@ -121,6 +125,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { userChatHistory = null; sender = null; recipient = null; + sessionKey = null; ChatApiClient().chatLoginResponse = null; } @@ -165,8 +170,14 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { try { chatLoginResponse = await ChatApiClient().getChatLoginToken(moduleId, requestId, title, myId, assigneeEmployeeNumber); chatParticipantModel = await ChatApiClient().loadParticipants(moduleId, requestId, assigneeEmployeeNumber); - sender = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber == myId); - recipient = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber == assigneeEmployeeNumber); + // `firstWhere` used to throw StateError when a participant was missing, + // which aborted the whole setup and left the chat unusable. + final List participants = chatParticipantModel?.participants ?? []; + sender = participants.cast().firstWhere((participant) => participant?.employeeNumber == myId, orElse: () => null); + recipient = participants.cast().firstWhere((participant) => participant?.employeeNumber == assigneeEmployeeNumber, orElse: () => null); + if (chatLoginResponse != null) { + sessionKey = buildSessionKey(moduleId, requestId); + } } catch (ex) { if (kDebugMode) { print('⚠️ Error in getUserAutoLoginTokenSilent: $ex'); diff --git a/lib/modules/cx_module/chat/chat_widget.dart b/lib/modules/cx_module/chat/chat_widget.dart index 8d5011e8..f9b04619 100644 --- a/lib/modules/cx_module/chat/chat_widget.dart +++ b/lib/modules/cx_module/chat/chat_widget.dart @@ -1,5 +1,3 @@ - - import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -12,99 +10,157 @@ import 'chat_page.dart'; import 'chat_provider.dart'; class ChatWidget extends StatefulWidget { - int moduleId; - int requestId; - String? assigneeEmployeeNumber; - String? myLoginUserID; - String? contactEmployeeINumber; - bool isReadOnly; - bool isShow; - String title; - - ChatWidget( - {Key? key, - this.isReadOnly = false, - this.title = "Chat", - required this.moduleId, - required this.requestId, - this.assigneeEmployeeNumber, - this.myLoginUserID, - this.contactEmployeeINumber, - this.isShow = false}) - : super(key: key); + final int moduleId; + final int requestId; + final String? assigneeEmployeeNumber; + final String? myLoginUserID; + final String? contactEmployeeINumber; + final bool isReadOnly; + final bool isShow; + final String title; + + const ChatWidget({ + Key? key, + this.isReadOnly = false, + this.title = "Chat", + required this.moduleId, + required this.requestId, + this.assigneeEmployeeNumber, + this.myLoginUserID, + this.contactEmployeeINumber, + this.isShow = false, + }) : super(key: key); @override - _ChatWidgetState createState() { - return _ChatWidgetState(); - } + State createState() => _ChatWidgetState(); } class _ChatWidgetState extends State { @override void initState() { super.initState(); + if (!widget.isShow) return; WidgetsBinding.instance.addPostFrameCallback((_) { - Provider.of(context, listen: false).reset(); - getChatToken(); + if (!mounted) return; + _prepareChatSession(); }); } - void getChatToken() { - // ChatProvider cProvider = Provider.of(context, listen: false); - // if (cProvider.chatLoginResponse != null && cProvider.referenceID == widget.requestId) return; - // // String assigneeEmployeeNumber = Provider.of(context, listen: false).currentWorkOrder?.data?.assignedEmployee?.employeeId ?? ""; - // String myEmployeeId = widget.myLoginUserID; - // - // String receiver = context.userProvider.isNurse ? widget.assigneeEmployeeNumber : widget.myEmployeeID; - - ChatProvider cProvider = Provider.of(context, listen: false); - if (cProvider.chatLoginResponse != null && cProvider.referenceID == widget.requestId) return; - String assigneeEmployeeNumber = widget.assigneeEmployeeNumber ?? Provider.of(context, listen: false).currentWorkOrder?.data?.assignedEmployee?.employeeId ?? ""; - String myEmployeeId = widget.myLoginUserID ?? context.userProvider.user!.username!; - - String receiver = context.userProvider.isNurse - ? assigneeEmployeeNumber - : (context.userProvider.isEngineer - ? (widget.contactEmployeeINumber ?? Provider.of(context, listen: false).currentWorkOrder!.data!.workOrderContactPerson.first.employeeId!) - : ""); - - cProvider.getUserAutoLoginTokenSilent(widget.moduleId, widget.requestId, widget.title, myEmployeeId, receiver, isMounted: mounted); + @override + void didUpdateWidget(covariant ChatWidget oldWidget) { + super.didUpdateWidget(oldWidget); + // Re-prepare when the widget is pointed at a different request. + if (widget.isShow && (oldWidget.requestId != widget.requestId || oldWidget.moduleId != widget.moduleId)) { + _prepareChatSession(); + } } - @override - void dispose() { - super.dispose(); + /// Fetches the chat token + participants, unless the exact same conversation + /// is already loaded in the (globally shared) [ChatProvider]. + /// + /// NOTE: `reset()` is intentionally NOT called here. `ChatProvider` is a + /// singleton, so resetting on every mount used to drop a live SignalR socket + /// and made the "already loaded" guard below dead code (it always re-fetched). + /// `getUserAutoLoginTokenSilent()` performs its own reset internally. + void _prepareChatSession() { + final ChatProvider cProvider = context.read(); + final String sessionKey = ChatProvider.buildSessionKey(widget.moduleId, widget.requestId); + if (cProvider.chatLoginTokenLoading) return; + if (cProvider.chatLoginResponse != null && cProvider.sessionKey == sessionKey) return; + + final String? myEmployeeId = widget.myLoginUserID ?? context.userProvider.user?.username; + final String? receiver = _resolveReceiver(); + if (myEmployeeId == null || myEmployeeId.isEmpty || receiver == null || receiver.isEmpty) { + // Not enough information to open a conversation (e.g. no assignee / + // contact person yet) - keep the icon idle instead of crashing on a + // null assertion. + return; + } + + cProvider.getUserAutoLoginTokenSilent( + widget.moduleId, + widget.requestId, + widget.title, + myEmployeeId, + receiver, + isMounted: mounted, + ); + } + + /// Nurse -> chats the assigned engineer. Engineer -> chats the contact person. + String? _resolveReceiver() { + final workOrder = context.read().currentWorkOrder?.data; + + if (context.userProvider.isNurse) { + return widget.assigneeEmployeeNumber ?? workOrder?.assignedEmployee?.employeeId; + } + if (context.userProvider.isEngineer) { + if (widget.contactEmployeeINumber != null && widget.contactEmployeeINumber!.isNotEmpty) { + return widget.contactEmployeeINumber; + } + final contacts = workOrder?.workOrderContactPerson; + if (contacts != null && contacts.isNotEmpty) { + return contacts.first.employeeId; + } + return workOrder?.workOrderCreatedBy?.employeeId; + } + return null; + } + + void _openChat() { + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => ChatPage( + moduleId: widget.moduleId, + requestId: widget.requestId, + title: widget.title, + readOnly: widget.isReadOnly, + assigneeEmployeeNumber: widget.assigneeEmployeeNumber, + contactEmployeeINumber: widget.contactEmployeeINumber, + myLoginUserID: widget.myLoginUserID, + ), + ), + ); } @override Widget build(BuildContext context) { - return widget.isShow - ? Consumer(builder: (pContext, chatProvider, _) { - final int unreadCount = (!chatProvider.chatLoginTokenLoading && (chatProvider.chatParticipantModel?.unreadCount ?? 0) > 0) ? chatProvider.chatParticipantModel!.unreadCount! : 0; - return CustomBadge2( - value: unreadCount, - top: 2, - right: 2, - minSize: 18, - child: IconButton( - icon: const Icon(Icons.chat_bubble), - onPressed: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => ChatPage( - moduleId: widget.moduleId, - requestId: widget.requestId, - title: widget.title, - readOnly: widget.isReadOnly, - assigneeEmployeeNumber: widget.assigneeEmployeeNumber, - contactEmployeeINumber: widget.contactEmployeeINumber, - myLoginUserID: widget.myLoginUserID, - ))); - }, - ).toShimmer(context: context, isShow: chatProvider.chatLoginTokenLoading, radius: 30, height: 30, width: 30), - ); - }) - : const SizedBox(); + if (!widget.isShow) return const SizedBox(); + + // Selector (instead of Consumer) so the badge does not rebuild on every + // incoming message / typing event pushed through ChatProvider. + return Selector( + selector: (_, provider) => _ChatBadgeState( + isLoading: provider.chatLoginTokenLoading, + unreadCount: provider.chatParticipantModel?.unreadCount ?? 0, + ), + builder: (_, state, __) => CustomBadge2( + value: state.effectiveUnread, + top: 2, + right: 2, + minSize: 18, + child: IconButton( + icon: const Icon(Icons.chat_bubble), + onPressed: _openChat, + ).toShimmer(context: context, isShow: state.isLoading, radius: 30, height: 30, width: 30), + ), + ); } } + +class _ChatBadgeState { + final bool isLoading; + final int unreadCount; + + const _ChatBadgeState({required this.isLoading, required this.unreadCount}); + + int get effectiveUnread => isLoading ? 0 : unreadCount; + + @override + bool operator ==(Object other) => + identical(this, other) || (other is _ChatBadgeState && other.isLoading == isLoading && other.effectiveUnread == effectiveUnread); + + @override + int get hashCode => Object.hash(isLoading, effectiveUnread); +}