diff --git a/lib/business_logic/listener_model/tui_group_listener_model.dart b/lib/business_logic/listener_model/tui_group_listener_model.dart index aabe54c..274e7ce 100644 --- a/lib/business_logic/listener_model/tui_group_listener_model.dart +++ b/lib/business_logic/listener_model/tui_group_listener_model.dart @@ -27,6 +27,7 @@ import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/group_chat_status_manager.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; enum UpdateType { groupInfo, memberList, joinApplicationList, groupDismissed, kickedFromGroup } @@ -60,6 +61,8 @@ class TUIGroupListenerModel extends ChangeNotifier { _groupListener = V2TimGroupListener(onMemberInvited: (groupID, opUser, memberList) async { // 受邀进入群:清除“已退出/被踢出”状态 await GroupChatStatusManager.instance.clearGroupChatStatus(groupID); + // 清理组合头像缓存,确保成员变化后头像能刷新 + Avatar.clearGroupCompositeCache(groupID); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); notifyListeners(); }, onMemberKicked: (groupID, opUser, memberList) async { @@ -71,11 +74,15 @@ class TUIGroupListenerModel extends ChangeNotifier { // 为被踢出的用户创建本地提示消息 await _insertKickedOutMessage(groupID, opUser); + // 清理组合头像缓存 + Avatar.clearGroupCompositeCache(groupID); + final groupName = await _getGroupName(groupID); _needUpdate = NeedUpdate(groupID, UpdateType.kickedFromGroup, groupName); notifyListeners(); } else { // 其他成员被踢出时,也需要更新成员列表 + Avatar.clearGroupCompositeCache(groupID); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); notifyListeners(); } @@ -89,9 +96,13 @@ class TUIGroupListenerModel extends ChangeNotifier { await GroupChatStatusManager.instance.clearGroupChatStatus(groupID); } + // 成员进入:清理组合头像缓存 + Avatar.clearGroupCompositeCache(groupID); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); notifyListeners(); }, onMemberLeave: (String groupID, V2TimGroupMemberInfo member) { + // 成员离开:清理组合头像缓存 + Avatar.clearGroupCompositeCache(groupID); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); notifyListeners(); }, onGroupInfoChanged: (groupID, changeInfos) { @@ -108,6 +119,8 @@ class TUIGroupListenerModel extends ChangeNotifier { chatViewModel.refreshGroupApplicationList(); notifyListeners(); }, onGroupDismissed: (String groupID, V2TimGroupMemberInfo opUser) async { + // 群解散:清理组合头像缓存 + Avatar.clearGroupCompositeCache(groupID); _deleteGroupConversation(groupID); final groupName = await _getGroupName(groupID); _needUpdate = NeedUpdate(groupID, UpdateType.groupDismissed, groupName); diff --git a/lib/data_services/core/tim_uikit_config.dart b/lib/data_services/core/tim_uikit_config.dart index 68e7cb5..197e02a 100644 --- a/lib/data_services/core/tim_uikit_config.dart +++ b/lib/data_services/core/tim_uikit_config.dart @@ -44,12 +44,23 @@ class TIMUIKitConfig { /// with a default value of `true`, and backward-compatibility. final bool isPreloadMessagesAfterInit; - const TIMUIKitConfig( { + /// 启用群组对话中的复合头像功能 + /// 若启用此功能,群组头像将通过组合多个成员头像的方式进行呈现 + /// [默认值]:true + final bool enableGroupCompositeAvatar; + + /// 在群组头像中可组合的成员头像的最大数量 + /// [默认值]:4 + final int maxCompositeAvatarsCount; + + const TIMUIKitConfig({ this.defaultAvatarAssetPath, this.showDesktopModalFunc, this.isPreloadMessagesAfterInit = true, this.defaultAvatarBorderRadius, this.isCheckDiskStorageSpace = true, this.isShowOnlineStatus = true, + this.enableGroupCompositeAvatar = true, + this.maxCompositeAvatarsCount = 4, }); } diff --git a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart index d63fffa..de93371 100644 --- a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart +++ b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart @@ -494,7 +494,8 @@ class _TIMUIKitConversationState extends TIMUIKitState { ? onlineStatus : null, draftTimestamp: conversationItem.draftTimestamp, - convType: conversationItem.type), + convType: conversationItem.type, + groupID: conversationItem.groupID), onTap: () => onTapConvItem(conversationItem), ), ); diff --git a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart index e4d847c..2421da5 100644 --- a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart +++ b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart @@ -35,6 +35,8 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget { final V2TimUserStatus? onlineStatus; final int? convType; final bool isCurrent; + // 新增:群ID,用于头像组件组合头像 + final String? groupID; TIMUIKitConversationItem({ Key? key, @@ -51,6 +53,7 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget { this.draftTimestamp, this.lastMessageBuilder, this.convType, + this.groupID, }) : super(key: key); Widget _getShowMsgWidget(BuildContext context) { @@ -138,8 +141,8 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget { Container( padding: const EdgeInsets.only(top: 0, bottom: 2, right: 0), child: SizedBox( - width: isDesktopScreen ? 40 : 44, - height: isDesktopScreen ? 40 : 44, + width: isDesktopScreen ? 40 : 48, + height: isDesktopScreen ? 40 : 48, child: Stack( fit: StackFit.expand, clipBehavior: Clip.none, @@ -148,7 +151,8 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget { onlineStatus: onlineStatus, faceUrl: faceUrl, showName: nickName, - type: convType), + type: convType, + groupID: groupID), if (unreadCount != 0) Positioned( top: isDisturb ? -2.5 : -4.5, diff --git a/lib/ui/widgets/avatar.dart b/lib/ui/widgets/avatar.dart index 924efbe..6718ada 100644 --- a/lib/ui/widgets/avatar.dart +++ b/lib/ui/widgets/avatar.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'dart:math' as math; import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; @@ -10,6 +11,10 @@ import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implem import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_filter_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; class Avatar extends TIMUIKitStatelessWidget { final String faceUrl; @@ -23,6 +28,26 @@ class Avatar extends TIMUIKitStatelessWidget { final bool isCircle; // 是否显示为圆形头像 final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + // 新增:群ID,用于组合头像 + final String? groupID; + // 获取群服务 + final GroupServices _groupServices = serviceLocator(); + // 组合头像缓存,避免频繁重建导致的闪烁 + static final Map> _groupCompositeCache = {}; + static final Map>> _groupCompositeFutureCache = + {}; + + // 对外暴露:清理指定群的组合头像缓存 + static void clearGroupCompositeCache(String groupID) { + _groupCompositeCache.remove(groupID); + _groupCompositeFutureCache.remove(groupID); + } + + // 对外暴露:清理所有组合头像缓存 + static void clearAllGroupCompositeCache() { + _groupCompositeCache.clear(); + _groupCompositeFutureCache.clear(); + } Avatar( {Key? key, @@ -33,10 +58,205 @@ class Avatar extends TIMUIKitStatelessWidget { this.isFromLocalAsset = false, this.borderRadius, this.isCircle = true, - this.type = 1}) + this.type = 1, + this.groupID}) : super(key: key); - Widget getImageWidget(BuildContext context, TUITheme theme) { + Future> _fetchGroupAvatarUrls( + String groupID, int maxCount) async { + final res = await _groupServices.getGroupMemberList( + groupID: groupID, + filter: GroupMemberFilterTypeEnum.V2TIM_GROUP_MEMBER_FILTER_ALL, + nextSeq: "0", + count: maxCount, + offset: 0); + if (res.code != 0 || res.data == null) { + return []; + } + final members = res.data!.memberInfoList ?? []; + final List urls = members + .whereType() + .map((m) => m.faceUrl ?? "") + .where((u) => u.isNotEmpty) + .take(maxCount) + .toList(); + return urls; + } + + // 组合头像布局:带背景和圆角(28),内部头像为圆形 + Widget _buildCompositeGrid(List u) { + final count = u.length; + const double spacing = 2.0; // 内部头像之间的间距 + const double outerPadding = 6.0; // 内头像距离外层的间距 + + List tiles = []; + for (var i = 0; i < count; i++) { + tiles.add(ClipOval( + child: CachedNetworkImage( + imageUrl: u[i], + fadeInDuration: const Duration(milliseconds: 0), + fit: BoxFit.cover, + errorWidget: (context, _, __) => Image.asset( + TencentUtils.checkString( + selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ?? + 'images/default_c2c_head.png', + package: + selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null + ? null + : 'tencent_cloud_chat_uikit', + fit: BoxFit.cover, + ), + ), + )); + } + + Widget gridChild; + if (count <= 1) { + gridChild = tiles.isNotEmpty ? tiles[0] : const SizedBox.shrink(); + } else if (count == 2) { + gridChild = Row(children: [ + Expanded(child: AspectRatio(aspectRatio: 1, child: tiles[0])), + const SizedBox(width: spacing), + Expanded(child: AspectRatio(aspectRatio: 1, child: tiles[1])), + ]); + } else if (count == 3) { + // 三宫格(上2下1),固定方块尺寸,整体垂直水平居中,并轻微下移修正视觉偏上 + gridChild = LayoutBuilder( + builder: (context, constraints) { + // 以最小边为准,保证在非正方形约束下也能居中 + final double side = + math.min(constraints.maxWidth, constraints.maxHeight); + final double tileSize = (side - spacing) / 2; + // 轻微向下偏移,修正视觉上略靠上的问题 + // const double vBias = spacing; // + 1.0; + return Center( + child: SizedBox( + width: tileSize * 2 + spacing, + height: tileSize * 2 + spacing, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: tileSize, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: tileSize, + height: tileSize, + child: tiles[0]), + const SizedBox(width: spacing), + SizedBox( + width: tileSize, + height: tileSize, + child: tiles[1]), + ], + ), + ), + const SizedBox(height: spacing), + SizedBox( + height: tileSize, + child: Center( + child: SizedBox( + width: tileSize, + height: tileSize, + child: tiles[2], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } else { + // 4个或以上按2x2 + gridChild = Column(children: [ + Expanded( + child: Row(children: [ + Expanded(child: AspectRatio(aspectRatio: 1, child: tiles[0])), + const SizedBox(width: spacing), + Expanded(child: AspectRatio(aspectRatio: 1, child: tiles[1])), + ])), + const SizedBox(height: spacing), + Expanded( + child: Row(children: [ + Expanded(child: AspectRatio(aspectRatio: 1, child: tiles[2])), + const SizedBox(width: spacing), + Expanded(child: AspectRatio(aspectRatio: 1, child: tiles[3])), + ])), + ]); + } + + return Container( + decoration: BoxDecoration( + color: const Color(0xFFEFEFEF), + borderRadius: BorderRadius.circular(14), + ), + child: Padding( + padding: const EdgeInsets.all(outerPadding), + child: gridChild, + ), + ); + } + + // 组合头像占位:减少切换的闪动 + Widget _compositePlaceholder() { + return Container( + decoration: BoxDecoration( + color: const Color(0xFFEFEFEF), + borderRadius: BorderRadius.circular(28), + ), + ); + } + + // 组合头像子项 + Widget _compositeAvatar(BuildContext context, TUITheme theme) { + final config = selfInfoViewModel.globalConfig; + final enableComposite = config?.enableGroupCompositeAvatar ?? true; + final maxCount = config?.maxCompositeAvatarsCount ?? 4; + + // 若非群聊或未开启组合头像,则回退到单一头像 + if ((type ?? 1) != 2 || + !enableComposite || + groupID == null || + groupID!.isEmpty) { + return _singleAvatarWidget(context, theme); + } + + // 命中缓存,直接同步渲染,避免闪动 + final cached = _groupCompositeCache[groupID!]; + if (cached != null && cached.isNotEmpty) { + return _buildCompositeGrid(cached); + } + + final future = _groupCompositeFutureCache[groupID!] ??= + _fetchGroupAvatarUrls(groupID!, maxCount); + + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + // 等待时使用占位背景,避免先显示单头像再切换 + return _compositePlaceholder(); + } + if (snapshot.hasError || !snapshot.hasData) { + return _singleAvatarWidget(context, theme); + } + final urls = snapshot.data ?? []; + if (urls.isEmpty) { + return _singleAvatarWidget(context, theme); + } + // 写入缓存以便后续快速渲染 + _groupCompositeCache[groupID!] = urls; + return _buildCompositeGrid(urls); + }, + ); + } + + // 提取原有单头像逻辑,避免在组合头像回退时发生递归 + Widget _singleAvatarWidget(BuildContext context, TUITheme theme) { Widget defaultAvatar() { if (type == 1) { return Image.asset( @@ -61,7 +281,6 @@ class Avatar extends TIMUIKitStatelessWidget { } } - // final emptyAvatarBuilder = coreService.emptyAvatarBuilder; if (faceUrl != "" && faceUrl.isNotEmpty) { if (isFromLocalAsset) { return Image.asset( @@ -98,6 +317,21 @@ class Avatar extends TIMUIKitStatelessWidget { } } + Widget getImageWidget(BuildContext context, TUITheme theme) { + // 若满足组合头像条件则优先组合 + final config = selfInfoViewModel.globalConfig; + final enableComposite = config?.enableGroupCompositeAvatar ?? true; + if ((type ?? 1) == 2 && + enableComposite && + groupID != null && + groupID!.isNotEmpty) { + return _compositeAvatar(context, theme); + } + + // 原有单头像逻辑 + return _singleAvatarWidget(context, theme); + } + ImageProvider getImageProvider() { ImageProvider defaultAvatar() { if (type == 1) { @@ -145,8 +379,18 @@ class Avatar extends TIMUIKitStatelessWidget { Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { final TUITheme theme = value.theme; - // 根据isCircle参数决定borderRadius + // 根据isCircle参数决定borderRadius;群组合头像强制4圆角 BorderRadius getAvatarBorderRadius() { + final config = selfInfoViewModel.globalConfig; + // 默认启用组合头像(当 globalConfig 为空时也生效) + final enableComposite = config?.enableGroupCompositeAvatar ?? true; + final bool isCompositeCase = ((type ?? 1) == 2) && + enableComposite && + groupID != null && + groupID!.isNotEmpty; + if (isCompositeCase) { + return BorderRadius.circular(14); + } if (isCircle) { return BorderRadius.circular(200); // 使用大圆角值来实现圆形效果 } diff --git a/lib/ui/widgets/recent_conversation_list.dart b/lib/ui/widgets/recent_conversation_list.dart index 8c39c88..cd349be 100644 --- a/lib/ui/widgets/recent_conversation_list.dart +++ b/lib/ui/widgets/recent_conversation_list.dart @@ -99,6 +99,7 @@ class _RecentForwardListState extends TIMUIKitState { faceUrl: faceUrl, showName: showName, type: conversation.type, + groupID: conversation.groupID, ), ), Expanded(