diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index b46b7a439..9de43693a 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -2193,19 +2193,21 @@ Widget buildRemoteBlock({required Widget child, WhetherUseRemoteBlock? use}) { )); } -Widget unreadMessageCountBuilder(RxInt? count) { +Widget unreadMessageCountBuilder(RxInt? count, + {double? size, double? fontSize, double? marginLeft}) { return Obx(() => Offstage( offstage: !((count?.value ?? 0) > 0), child: Container( - width: 16, - height: 16, + width: size ?? 16, + height: size ?? 16, decoration: BoxDecoration( color: Colors.red, shape: BoxShape.circle, ), child: Center( child: Text("${count?.value ?? 0}", - maxLines: 1, style: TextStyle(color: Colors.white, fontSize: 10)), + maxLines: 1, + style: TextStyle(color: Colors.white, fontSize: fontSize ?? 10)), ), - ).marginOnly(left: 4))); + ).marginOnly(left: marginLeft ?? 4))); } diff --git a/flutter/lib/common/widgets/chat_page.dart b/flutter/lib/common/widgets/chat_page.dart index 0e6be569e..a79e673d5 100644 --- a/flutter/lib/common/widgets/chat_page.dart +++ b/flutter/lib/common/widgets/chat_page.dart @@ -7,10 +7,15 @@ import 'package:provider/provider.dart'; import '../../mobile/pages/home_page.dart'; +enum ChatPageType { + mobileMain, +} + class ChatPage extends StatelessWidget implements PageShape { late final ChatModel chatModel; + final ChatPageType? type; - ChatPage({ChatModel? chatModel}) { + ChatPage({ChatModel? chatModel, this.type}) { this.chatModel = chatModel ?? gFFI.chatModel; } @@ -22,17 +27,40 @@ class ChatPage extends StatelessWidget implements PageShape { @override final appBarActions = [ - PopupMenuButton( + PopupMenuButton( tooltip: "", - icon: Icon(Icons.group), + icon: Stack( + children: [ + Icon(Icons.group), + Positioned( + top: 0, + right: 0, + child: unreadMessageCountBuilder(gFFI.chatModel.mobileUnreadSum, + marginLeft: 0, size: 12, fontSize: 8)) + ], + ), itemBuilder: (context) { // only mobile need [appBarActions], just bind gFFI.chatModel final chatModel = gFFI.chatModel; return chatModel.messages.entries.map((entry) { final id = entry.key; final user = entry.value.chatUser; - return PopupMenuItem( - child: Text("${user.firstName} ${user.id}"), + final client = gFFI.serverModel.clients + .firstWhereOrNull((e) => e.id == id.connId); + return PopupMenuItem( + child: Row( + children: [ + Icon( + id.isOut + ? Icons.call_made_rounded + : Icons.call_received_rounded, + color: MyTheme.accent) + .marginOnly(right: 6), + Text("${user.firstName} ${user.id}"), + if (client != null) + unreadMessageCountBuilder(client.unreadChatMessageCount) + ], + ), value: id, ); }).toList(); @@ -57,9 +85,14 @@ class ChatPage extends StatelessWidget implements PageShape { final chat = DashChat( onSend: chatModel.send, currentUser: chatModel.me, - messages: - chatModel.messages[chatModel.currentID]?.chatMessages ?? - [], + messages: chatModel + .messages[chatModel.currentKey]?.chatMessages ?? + [], + readOnly: type == ChatPageType.mobileMain && + (chatModel.currentKey.connId == + ChatModel.clientModeID || + gFFI.serverModel.clients.every( + (e) => e.id != chatModel.currentKey.connId)), inputOptions: InputOptions( focusNode: chatModel.inputNode, textController: chatModel.textController, @@ -128,12 +161,18 @@ class ChatPage extends StatelessWidget implements PageShape { return SelectionArea(child: chat); }), desktopType == DesktopType.cm || - chatModel.currentID == ChatModel.clientModeID + type != ChatPageType.mobileMain || + currentUser == null ? SizedBox.shrink() : Padding( padding: EdgeInsets.all(12), child: Row( children: [ + Icon( + chatModel.currentKey.isOut + ? Icons.call_made_rounded + : Icons.call_received_rounded, + color: MyTheme.accent), Icon(Icons.account_circle, color: MyTheme.accent80), SizedBox(width: 5), Text( diff --git a/flutter/lib/common/widgets/overlay.dart b/flutter/lib/common/widgets/overlay.dart index 73210b5fe..a3c59d6b1 100644 --- a/flutter/lib/common/widgets/overlay.dart +++ b/flutter/lib/common/widgets/overlay.dart @@ -32,7 +32,7 @@ class DraggableChatWindow extends StatelessWidget { width: width, height: height, builder: (context, onPanUpdate) { - return isIOS + final child = isIOS ? ChatPage(chatModel: chatModel) : Scaffold( resizeToAvoidBottomInset: false, @@ -44,6 +44,10 @@ class DraggableChatWindow extends StatelessWidget { ), body: ChatPage(chatModel: chatModel), ); + return Container( + decoration: + BoxDecoration(border: Border.all(color: MyTheme.border)), + child: child); }); } diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 25be23142..b560d1a41 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -100,14 +100,14 @@ class ConnectionManagerState extends State { gFFI.serverModel.tabController.onSelected = (client_id_str) { final client_id = int.tryParse(client_id_str); if (client_id != null) { - gFFI.chatModel.changeCurrentID(client_id); final client = gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == client_id); if (client != null) { + gFFI.chatModel.changeCurrentID(MessageKey(client.peerId, client.id)); if (client.unreadChatMessageCount.value > 0) { Future.delayed(Duration.zero, () { client.unreadChatMessageCount.value = 0; - gFFI.chatModel.showChatPage(client.id); + gFFI.chatModel.showChatPage(MessageKey(client.peerId, client.id)); }); } windowManager.setTitle(getWindowNameWithId(client.peerId)); @@ -444,7 +444,8 @@ class _CmHeaderState extends State<_CmHeader> child: IconButton( onPressed: () => checkClickTime( client.id, - () => gFFI.chatModel.toggleCMChatPage(client.id), + () => gFFI.chatModel + .toggleCMChatPage(MessageKey(client.peerId, client.id)), ), icon: SvgPicture.asset('assets/chat2.svg'), splashRadius: kDesktopIconButtonSplashRadius, diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index df5530bfc..dea145888 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1453,7 +1453,8 @@ class _ChatMenuState extends State<_ChatMenu> { initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight); } - widget.ffi.chatModel.changeCurrentID(ChatModel.clientModeID); + widget.ffi.chatModel.changeCurrentID( + MessageKey(widget.ffi.id, ChatModel.clientModeID)); widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos); }); } diff --git a/flutter/lib/mobile/pages/connection_page.dart b/flutter/lib/mobile/pages/connection_page.dart index 2118b2b2f..d736e0ace 100644 --- a/flutter/lib/mobile/pages/connection_page.dart +++ b/flutter/lib/mobile/pages/connection_page.dart @@ -37,6 +37,7 @@ class ConnectionPage extends StatefulWidget implements PageShape { class _ConnectionPageState extends State { /// Controller for the id input bar. final _idController = IDTextEditingController(); + final RxBool _idEmpty = true.obs; /// Update url. If it's not null, means an update is available. var _updateUrl = ''; @@ -60,6 +61,10 @@ class _ConnectionPageState extends State { if (_updateUrl.isNotEmpty) setState(() {}); }); } + + _idController.addListener(() { + _idEmpty.value = _idController.text.isEmpty; + }); } @override @@ -158,6 +163,14 @@ class _ConnectionPageState extends State { ), ), ), + Obx(() => Offstage( + offstage: _idEmpty.value, + child: IconButton( + onPressed: () { + _idController.clear(); + }, + icon: Icon(Icons.clear, color: MyTheme.darkGray)), + )), SizedBox( width: 60, height: 60, diff --git a/flutter/lib/mobile/pages/home_page.dart b/flutter/lib/mobile/pages/home_page.dart index f806c2576..61674807a 100644 --- a/flutter/lib/mobile/pages/home_page.dart +++ b/flutter/lib/mobile/pages/home_page.dart @@ -22,6 +22,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { var _selectedIndex = 0; + int get selectedIndex => _selectedIndex; final List _pages = []; void refreshPages() { @@ -40,7 +41,7 @@ class _HomePageState extends State { _pages.clear(); _pages.add(ConnectionPage()); if (isAndroid) { - _pages.addAll([ChatPage(), ServerPage()]); + _pages.addAll([ChatPage(type: ChatPageType.mobileMain), ServerPage()]); } _pages.add(SettingsPage()); } @@ -82,6 +83,8 @@ class _HomePageState extends State { gFFI.chatModel.hideChatWindowOverlay(); } _selectedIndex = index; + gFFI.chatModel + .mobileClearClientUnread(gFFI.chatModel.currentKey.connId); }), ), body: _pages.elementAt(_selectedIndex), diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 028fa2ed9..61867daa2 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -351,8 +351,8 @@ class _RemotePageState extends State { color: Colors.white, icon: Icon(Icons.message), onPressed: () { - gFFI.chatModel - .changeCurrentID(ChatModel.clientModeID); + gFFI.chatModel.changeCurrentID(MessageKey( + widget.id, ChatModel.clientModeID)); gFFI.chatModel.toggleChatOverlay(); }, ) diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index 8eadcaa2b..7ca3a9cb2 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart'; +import 'package:flutter_hbb/models/chat_model.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; @@ -419,7 +420,8 @@ class ConnectionManager extends StatelessWidget { ? const SizedBox.shrink() : IconButton( onPressed: () { - gFFI.chatModel.changeCurrentID(client.id); + gFFI.chatModel.changeCurrentID( + MessageKey(client.peerId, client.id)); final bar = navigationBarKey.currentWidget; if (bar != null) { bar as BottomNavigationBar; diff --git a/flutter/lib/models/chat_model.dart b/flutter/lib/models/chat_model.dart index 4eaf75d17..a50a7deb1 100644 --- a/flutter/lib/models/chat_model.dart +++ b/flutter/lib/models/chat_model.dart @@ -7,6 +7,7 @@ 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_rx/src/rx_types/rx_types.dart'; @@ -20,6 +21,24 @@ 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; @@ -49,6 +68,7 @@ class ChatModel with ChangeNotifier { Rx get voiceCallStatus => _voiceCallStatus; TextEditingController textController = TextEditingController(); + RxInt mobileUnreadSum = 0.obs; @override void dispose() { @@ -61,15 +81,14 @@ class ChatModel with ChangeNotifier { firstName: translate("Me"), ); - late final Map _messages = {}..[clientModeID] = - MessageBody(me, []); + late final Map _messages = {}; - var _currentID = clientModeID; + MessageKey _currentKey = MessageKey('', clientModeID); late bool _isShowCMChatPage = false; - Map get messages => _messages; + Map get messages => _messages; - int get currentID => _currentID; + MessageKey get currentKey => _currentKey; bool get isShowCMChatPage => _isShowCMChatPage; @@ -119,15 +138,7 @@ class ChatModel with ChangeNotifier { ); } - ChatUser get currentUser { - final user = messages[currentID]?.chatUser; - if (user == null) { - _currentID = clientModeID; - return me; - } else { - return user; - } - } + ChatUser? get currentUser => _messages[_currentKey]?.chatUser; showChatIconOverlay({Offset offset = const Offset(200, 50)}) { if (chatIconOverlayEntry != null) { @@ -233,11 +244,11 @@ class ChatModel with ChangeNotifier { } } - showChatPage(int id) async { + showChatPage(MessageKey key) async { if (isDesktop) { if (isConnManager) { if (!_isShowCMChatPage) { - await toggleCMChatPage(id); + await toggleCMChatPage(key); } } else { if (_isChatOverlayHide()) { @@ -245,7 +256,7 @@ class ChatModel with ChangeNotifier { } } } else { - if (id == clientModeID) { + if (key.connId == clientModeID) { if (_isChatOverlayHide()) { await toggleChatOverlay(); } @@ -253,9 +264,9 @@ class ChatModel with ChangeNotifier { } } - toggleCMChatPage(int id) async { - if (gFFI.chatModel.currentID != id) { - gFFI.chatModel.changeCurrentID(id); + toggleCMChatPage(MessageKey key) async { + if (gFFI.chatModel.currentKey != key) { + gFFI.chatModel.changeCurrentID(key); } if (_isShowCMChatPage) { _isShowCMChatPage = !_isShowCMChatPage; @@ -273,25 +284,29 @@ class ChatModel with ChangeNotifier { } } - changeCurrentID(int id) { - if (_messages.containsKey(id)) { - _currentID = id; + changeCurrentID(MessageKey key) { + updateConnIdOfKey(key); + if (_messages.containsKey(key)) { + _currentKey = key; notifyListeners(); } else { - final client = parent.target?.serverModel.clients - .firstWhere((client) => client.id == id); - if (client == null) { - return debugPrint( - "Failed to changeCurrentID,remote user doesn't exist"); + 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; } final chatUser = ChatUser( - id: client.peerId, - firstName: client.name, + id: key.peerId, + firstName: peerName, ); - _messages[id] = MessageBody(chatUser, []); - _currentID = id; + _messages[key] = MessageBody(chatUser, []); + _currentKey = key; notifyListeners(); } + mobileClearClientUnread(key.connId); } receive(int id, String text) async { @@ -304,23 +319,33 @@ class ChatModel with ChangeNotifier { 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) { + if (!isDesktop && chatIconOverlayEntry == null && id == clientModeID) { showChatIconOverlay(); } // show chat page - await showChatPage(id); - - int toId = currentID; - + await showChatPage(messagekey); late final ChatUser chatUser; if (id == clientModeID) { chatUser = ChatUser( firstName: session.ffiModel.pi.username, - id: session.id, + id: peerId, ); - toId = id; if (isDesktop) { if (Get.isRegistered()) { @@ -339,14 +364,18 @@ class ChatModel with ChangeNotifier { } } else { if (notSelected) { - UnreadChatCountState.find(session.id).value += 1; + UnreadChatCountState.find(peerId).value += 1; } } } } } else { - final client = - session.serverModel.clients.firstWhere((client) => client.id == id); + final client = session.serverModel.clients + .firstWhereOrNull((client) => client.id == id); + if (client == null) { + debugPrint("Failed to receive msg, client is null"); + return; + } if (isDesktop) { window_on_top(null); // disable auto jumpTo other tab when hasFocus, and mark unread message @@ -356,20 +385,23 @@ class ChatModel with ChangeNotifier { client.unreadChatMessageCount.value += 1; } else { parent.target?.serverModel.jumpTo(id); - toId = id; } } else { - toId = id; + if (HomePage.homeKey.currentState?.selectedIndex != 1 || + _currentKey.peerId != client.peerId) { + client.unreadChatMessageCount.value += 1; + mobileUpdateUnreadSum(); + } } chatUser = ChatUser(id: client.peerId, firstName: client.name); } - - if (!_messages.containsKey(id)) { - _messages[id] = MessageBody(chatUser, []); - } - _messages[id]!.insert( + insertMessage(messagekey, ChatMessage(text: text, user: chatUser, createdAt: DateTime.now())); - _currentID = toId; + if (id == clientModeID || _currentKey.peerId.isEmpty) { + // Invalid + _currentKey = messagekey; + mobileClearClientUnread(messagekey.connId); + } notifyListeners(); } @@ -379,17 +411,63 @@ class ChatModel with ChangeNotifier { return; } message.text = trimmedText; - _messages[_currentID]?.insert(message); - if (_currentID == clientModeID && parent.target != null) { + insertMessage(_currentKey, message); + if (_currentKey.connId == clientModeID && parent.target != null) { bind.sessionSendChat(sessionId: sessionId, text: message.text); } else { - bind.cmSendChat(connId: _currentID, msg: message.text); + 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 old = _messages.remove(key); + if (old != null) { + _messages[key] = old; + } + } + if (_currentKey == key) { + _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(); diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 97f0e706c..3f5fb43f9 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/main.dart'; +import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; import 'package:wakelock/wakelock.dart'; @@ -474,6 +475,8 @@ class ServerModel with ChangeNotifier { cmHiddenTimer = null; }); } + parent.target?.chatModel + .updateConnIdOfKey(MessageKey(client.peerId, client.id)); } void showLoginDialog(Client client) {