feat: 新增基于成员头像动态生成的群组组合头像功能
This commit is contained in:
parent
077fc32e91
commit
ad2730c5c1
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -494,7 +494,8 @@ class _TIMUIKitConversationState extends TIMUIKitState<TIMUIKitConversation> {
|
|||
? onlineStatus
|
||||
: null,
|
||||
draftTimestamp: conversationItem.draftTimestamp,
|
||||
convType: conversationItem.type),
|
||||
convType: conversationItem.type,
|
||||
groupID: conversationItem.groupID),
|
||||
onTap: () => onTapConvItem(conversationItem),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<TUISelfInfoViewModel>();
|
||||
// 新增:群ID,用于组合头像
|
||||
final String? groupID;
|
||||
// 获取群服务
|
||||
final GroupServices _groupServices = serviceLocator<GroupServices>();
|
||||
// 组合头像缓存,避免频繁重建导致的闪烁
|
||||
static final Map<String, List<String>> _groupCompositeCache = {};
|
||||
static final Map<String, Future<List<String>>> _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<List<String>> _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<String> urls = members
|
||||
.whereType<V2TimGroupMemberFullInfo>()
|
||||
.map((m) => m.faceUrl ?? "")
|
||||
.where((u) => u.isNotEmpty)
|
||||
.take(maxCount)
|
||||
.toList();
|
||||
return urls;
|
||||
}
|
||||
|
||||
// 组合头像布局:带背景和圆角(28),内部头像为圆形
|
||||
Widget _buildCompositeGrid(List<String> u) {
|
||||
final count = u.length;
|
||||
const double spacing = 2.0; // 内部头像之间的间距
|
||||
const double outerPadding = 6.0; // 内头像距离外层的间距
|
||||
|
||||
List<Widget> 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<List<String>>(
|
||||
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); // 使用大圆角值来实现圆形效果
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ class _RecentForwardListState extends TIMUIKitState<RecentForwardList> {
|
|||
faceUrl: faceUrl,
|
||||
showName: showName,
|
||||
type: conversation.type,
|
||||
groupID: conversation.groupID,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
|
|
|||
Loading…
Reference in New Issue