import 'dart:async'; import 'package:dash_chat_2/dash_chat_2.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:draggable_float_widget/draggable_float_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/mobile/pages/home_page.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; import 'package:uuid/uuid.dart'; import 'package:window_manager/window_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../consts.dart'; import '../common.dart'; import '../common/widgets/overlay.dart'; import '../main.dart'; import 'model.dart'; class MessageKey { final String peerId; final int connId; bool get isOut => connId == ChatModel.clientModeID; MessageKey(this.peerId, this.connId); @override bool operator ==(other) { return other is MessageKey && other.peerId == peerId && other.isOut == isOut; } @override int get hashCode => peerId.hashCode ^ isOut.hashCode; } class MessageBody { ChatUser chatUser; List chatMessages; MessageBody(this.chatUser, this.chatMessages); void insert(ChatMessage cm) { chatMessages.insert(0, cm); } void clear() { chatMessages.clear(); } } class ChatModel with ChangeNotifier { static final clientModeID = -1; OverlayEntry? chatIconOverlayEntry; OverlayEntry? chatWindowOverlayEntry; bool isConnManager = false; RxBool isWindowFocus = true.obs; BlockableOverlayState _blockableOverlayState = BlockableOverlayState(); final Rx _voiceCallStatus = Rx(VoiceCallStatus.notStarted); Rx get voiceCallStatus => _voiceCallStatus; TextEditingController textController = TextEditingController(); RxInt mobileUnreadSum = 0.obs; MessageKey? latestReceivedKey; Offset chatWindowPosition = Offset(20, 80); void setChatWindowPosition(Offset position) { chatWindowPosition = position; notifyListeners(); } @override void dispose() { textController.dispose(); super.dispose(); } final ChatUser me = ChatUser( id: Uuid().v4().toString(), firstName: translate("Me"), ); late final Map _messages = {}; MessageKey _currentKey = MessageKey('', -2); // -2 is invalid value late bool _isShowCMSidePage = false; Map get messages => _messages; MessageKey get currentKey => _currentKey; bool get isShowCMSidePage => _isShowCMSidePage; void setOverlayState(BlockableOverlayState blockableOverlayState) { _blockableOverlayState = blockableOverlayState; _blockableOverlayState.addMiddleBlockedListener((v) { if (!v) { isWindowFocus.value = false; if (isWindowFocus.value) { isWindowFocus.toggle(); } } }); } final WeakReference parent; late final SessionID sessionId; late FocusNode inputNode; ChatModel(this.parent) { sessionId = parent.target!.sessionId; inputNode = FocusNode( onKey: (_, event) { bool isShiftPressed = event.isKeyPressed(LogicalKeyboardKey.shiftLeft); bool isEnterPressed = event.isKeyPressed(LogicalKeyboardKey.enter); // don't send empty messages if (isEnterPressed && isEnterPressed && textController.text.isEmpty) { return KeyEventResult.handled; } if (isEnterPressed && !isShiftPressed) { final ChatMessage message = ChatMessage( text: textController.text, user: me, createdAt: DateTime.now(), ); send(message); textController.clear(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, ); } ChatUser? get currentUser => _messages[_currentKey]?.chatUser; showChatIconOverlay({Offset offset = const Offset(200, 50)}) { if (chatIconOverlayEntry != null) { chatIconOverlayEntry!.remove(); } // mobile check navigationBar final bar = navigationBarKey.currentWidget; if (bar != null) { if ((bar as BottomNavigationBar).currentIndex == 1) { return; } } final overlayState = _blockableOverlayState.state; if (overlayState == null) return; final overlay = OverlayEntry(builder: (context) { return DraggableFloatWidget( config: DraggableFloatWidgetBaseConfig( initPositionYInTop: false, initPositionYMarginBorder: 100, borderTopContainTopBar: true, ), child: FloatingActionButton( onPressed: () { if (chatWindowOverlayEntry == null) { showChatWindowOverlay(); } else { hideChatWindowOverlay(); } }, backgroundColor: Theme.of(context).colorScheme.primary, child: SvgPicture.asset('assets/chat2.svg'), ), ); }); overlayState.insert(overlay); chatIconOverlayEntry = overlay; } hideChatIconOverlay() { if (chatIconOverlayEntry != null) { chatIconOverlayEntry!.remove(); chatIconOverlayEntry = null; } } showChatWindowOverlay({Offset? chatInitPos}) { if (chatWindowOverlayEntry != null) return; isWindowFocus.value = true; _blockableOverlayState.setMiddleBlocked(true); final overlayState = _blockableOverlayState.state; if (overlayState == null) return; if (isMobile && !gFFI.chatModel.currentKey.isOut && // not in remote page gFFI.chatModel.latestReceivedKey != null) { gFFI.chatModel.changeCurrentKey(gFFI.chatModel.latestReceivedKey!); gFFI.chatModel.mobileClearClientUnread(gFFI.chatModel.currentKey.connId); } final overlay = OverlayEntry(builder: (context) { return Listener( onPointerDown: (_) { if (!isWindowFocus.value) { isWindowFocus.value = true; _blockableOverlayState.setMiddleBlocked(true); } }, child: DraggableChatWindow( position: chatInitPos ?? chatWindowPosition, width: 250, height: 350, chatModel: this)); }); overlayState.insert(overlay); chatWindowOverlayEntry = overlay; requestChatInputFocus(); } hideChatWindowOverlay() { if (chatWindowOverlayEntry != null) { _blockableOverlayState.setMiddleBlocked(false); chatWindowOverlayEntry!.remove(); chatWindowOverlayEntry = null; return; } } _isChatOverlayHide() => ((!isDesktop && chatIconOverlayEntry == null) || chatWindowOverlayEntry == null); toggleChatOverlay({Offset? chatInitPos}) { if (_isChatOverlayHide()) { gFFI.invokeMethod("enable_soft_keyboard", true); if (!isDesktop) { showChatIconOverlay(); } showChatWindowOverlay(chatInitPos: chatInitPos); } else { hideChatIconOverlay(); hideChatWindowOverlay(); } } hideChatOverlay() { if (!_isChatOverlayHide()) { hideChatIconOverlay(); hideChatWindowOverlay(); } } showChatPage(MessageKey key) async { if (isDesktop) { if (isConnManager) { if (!_isShowCMSidePage) { await toggleCMChatPage(key); } } else { if (_isChatOverlayHide()) { await toggleChatOverlay(); } } } else { if (key.connId == clientModeID) { if (_isChatOverlayHide()) { await toggleChatOverlay(); } } } } toggleCMChatPage(MessageKey key) async { if (gFFI.chatModel.currentKey != key) { gFFI.chatModel.changeCurrentKey(key); } await toggleCMSidePage(); } var _togglingCMSidePage = false; // protect order for await toggleCMSidePage() async { if (_togglingCMSidePage) return false; _togglingCMSidePage = true; if (_isShowCMSidePage) { _isShowCMSidePage = !_isShowCMSidePage; notifyListeners(); await windowManager.show(); await windowManager.setSizeAlignment( kConnectionManagerWindowSizeClosedChat, Alignment.topRight); } else { requestChatInputFocus(); await windowManager.show(); await windowManager.setSizeAlignment( kConnectionManagerWindowSizeOpenChat, Alignment.topRight); _isShowCMSidePage = !_isShowCMSidePage; notifyListeners(); } _togglingCMSidePage = false; } changeCurrentKey(MessageKey key) { updateConnIdOfKey(key); String? peerName; if (key.connId == clientModeID) { peerName = parent.target?.ffiModel.pi.username; } else { peerName = parent.target?.serverModel.clients .firstWhereOrNull((client) => client.peerId == key.peerId) ?.name; } if (!_messages.containsKey(key)) { final chatUser = ChatUser( id: key.peerId, firstName: peerName, ); _messages[key] = MessageBody(chatUser, []); } else { if (peerName != null && peerName.isNotEmpty) { _messages[key]?.chatUser.firstName = peerName; } } _currentKey = key; notifyListeners(); mobileClearClientUnread(key.connId); } receive(int id, String text) async { final session = parent.target; if (session == null) { debugPrint("Failed to receive msg, session state is null"); return; } if (text.isEmpty) return; if (desktopType == DesktopType.cm) { await showCmWindow(); } String? peerId; if (id == clientModeID) { peerId = session.id; } else { peerId = session.serverModel.clients .firstWhereOrNull((e) => e.id == id) ?.peerId; } if (peerId == null) { debugPrint("Failed to receive msg, peerId is null"); return; } final messagekey = MessageKey(peerId, id); // mobile: first message show overlay icon if (!isDesktop && chatIconOverlayEntry == null) { showChatIconOverlay(); } // show chat page await showChatPage(messagekey); late final ChatUser chatUser; if (id == clientModeID) { chatUser = ChatUser( firstName: session.ffiModel.pi.username, id: peerId, ); if (isDesktop) { if (Get.isRegistered()) { DesktopTabController tabController = Get.find(); var index = tabController.state.value.tabs .indexWhere((e) => e.key == session.id); final notSelected = index >= 0 && tabController.state.value.selected != index; // minisized: top and switch tab // not minisized: add count if (await WindowController.fromWindowId(stateGlobal.windowId) .isMinimized()) { windowOnTop(stateGlobal.windowId); if (notSelected) { tabController.jumpTo(index); } } else { if (notSelected) { UnreadChatCountState.find(peerId).value += 1; } } } } } else { final client = session.serverModel.clients .firstWhereOrNull((client) => client.id == id); if (client == null) { debugPrint("Failed to receive msg, client is null"); return; } if (isDesktop) { windowOnTop(null); // disable auto jumpTo other tab when hasFocus, and mark unread message final currentSelectedTab = session.serverModel.tabController.state.value.selectedTabInfo; if (currentSelectedTab.key != id.toString() && inputNode.hasFocus) { client.unreadChatMessageCount.value += 1; } else { parent.target?.serverModel.jumpTo(id); } } else { if (HomePage.homeKey.currentState?.isChatPageCurrentTab != true || _currentKey != messagekey) { client.unreadChatMessageCount.value += 1; mobileUpdateUnreadSum(); } } chatUser = ChatUser(id: client.peerId, firstName: client.name); } insertMessage(messagekey, ChatMessage(text: text, user: chatUser, createdAt: DateTime.now())); if (id == clientModeID || _currentKey.peerId.isEmpty) { // client or invalid _currentKey = messagekey; mobileClearClientUnread(messagekey.connId); } latestReceivedKey = messagekey; notifyListeners(); } send(ChatMessage message) { String trimmedText = message.text.trim(); if (trimmedText.isEmpty) { return; } message.text = trimmedText; insertMessage(_currentKey, message); if (_currentKey.connId == clientModeID && parent.target != null) { bind.sessionSendChat(sessionId: sessionId, text: message.text); } else { bind.cmSendChat(connId: _currentKey.connId, msg: message.text); } notifyListeners(); inputNode.requestFocus(); } insertMessage(MessageKey key, ChatMessage message) { updateConnIdOfKey(key); if (!_messages.containsKey(key)) { _messages[key] = MessageBody(message.user, []); } _messages[key]?.insert(message); } updateConnIdOfKey(MessageKey key) { if (_messages.keys .toList() .firstWhereOrNull((e) => e == key && e.connId != key.connId) != null) { final value = _messages.remove(key); if (value != null) { _messages[key] = value; } } if (_currentKey == key || _currentKey.peerId.isEmpty) { _currentKey = key; // hash != assign } } void mobileUpdateUnreadSum() { if (!isMobile) return; var sum = 0; parent.target?.serverModel.clients .map((e) => sum += e.unreadChatMessageCount.value) .toList(); Future.delayed(Duration.zero, () { mobileUnreadSum.value = sum; }); } void mobileClearClientUnread(int id) { if (!isMobile) return; final client = parent.target?.serverModel.clients .firstWhereOrNull((client) => client.id == id); if (client != null) { Future.delayed(Duration.zero, () { client.unreadChatMessageCount.value = 0; mobileUpdateUnreadSum(); }); } } close() { hideChatIconOverlay(); hideChatWindowOverlay(); notifyListeners(); } resetClientMode() { _messages[clientModeID]?.clear(); } void requestChatInputFocus() { Timer(Duration(milliseconds: 100), () { if (inputNode.hasListeners && inputNode.canRequestFocus) { inputNode.requestFocus(); } }); } void onVoiceCallWaiting() { _voiceCallStatus.value = VoiceCallStatus.waitingForResponse; } void onVoiceCallStarted() { _voiceCallStatus.value = VoiceCallStatus.connected; } void onVoiceCallClosed(String reason) { _voiceCallStatus.value = VoiceCallStatus.notStarted; } void onVoiceCallIncoming() { if (isConnManager) { _voiceCallStatus.value = VoiceCallStatus.incoming; } } void closeVoiceCall() { bind.sessionCloseVoiceCall(sessionId: sessionId); } } enum VoiceCallStatus { notStarted, waitingForResponse, connected, // Connection manager only. incoming }