feat: 新增基于成员头像动态生成的群组组合头像功能

This commit is contained in:
Zeew 2025-08-20 21:47:45 +08:00
parent 077fc32e91
commit ad2730c5c1
6 changed files with 283 additions and 9 deletions

View File

@ -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/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.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/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 } enum UpdateType { groupInfo, memberList, joinApplicationList, groupDismissed, kickedFromGroup }
@ -60,6 +61,8 @@ class TUIGroupListenerModel extends ChangeNotifier {
_groupListener = V2TimGroupListener(onMemberInvited: (groupID, opUser, memberList) async { _groupListener = V2TimGroupListener(onMemberInvited: (groupID, opUser, memberList) async {
// 退/ // 退/
await GroupChatStatusManager.instance.clearGroupChatStatus(groupID); await GroupChatStatusManager.instance.clearGroupChatStatus(groupID);
//
Avatar.clearGroupCompositeCache(groupID);
_needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, "");
notifyListeners(); notifyListeners();
}, onMemberKicked: (groupID, opUser, memberList) async { }, onMemberKicked: (groupID, opUser, memberList) async {
@ -71,11 +74,15 @@ class TUIGroupListenerModel extends ChangeNotifier {
// //
await _insertKickedOutMessage(groupID, opUser); await _insertKickedOutMessage(groupID, opUser);
//
Avatar.clearGroupCompositeCache(groupID);
final groupName = await _getGroupName(groupID); final groupName = await _getGroupName(groupID);
_needUpdate = NeedUpdate(groupID, UpdateType.kickedFromGroup, groupName); _needUpdate = NeedUpdate(groupID, UpdateType.kickedFromGroup, groupName);
notifyListeners(); notifyListeners();
} else { } else {
// //
Avatar.clearGroupCompositeCache(groupID);
_needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, "");
notifyListeners(); notifyListeners();
} }
@ -89,9 +96,13 @@ class TUIGroupListenerModel extends ChangeNotifier {
await GroupChatStatusManager.instance.clearGroupChatStatus(groupID); await GroupChatStatusManager.instance.clearGroupChatStatus(groupID);
} }
//
Avatar.clearGroupCompositeCache(groupID);
_needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, "");
notifyListeners(); notifyListeners();
}, onMemberLeave: (String groupID, V2TimGroupMemberInfo member) { }, onMemberLeave: (String groupID, V2TimGroupMemberInfo member) {
//
Avatar.clearGroupCompositeCache(groupID);
_needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); _needUpdate = NeedUpdate(groupID, UpdateType.memberList, "");
notifyListeners(); notifyListeners();
}, onGroupInfoChanged: (groupID, changeInfos) { }, onGroupInfoChanged: (groupID, changeInfos) {
@ -108,6 +119,8 @@ class TUIGroupListenerModel extends ChangeNotifier {
chatViewModel.refreshGroupApplicationList(); chatViewModel.refreshGroupApplicationList();
notifyListeners(); notifyListeners();
}, onGroupDismissed: (String groupID, V2TimGroupMemberInfo opUser) async { }, onGroupDismissed: (String groupID, V2TimGroupMemberInfo opUser) async {
//
Avatar.clearGroupCompositeCache(groupID);
_deleteGroupConversation(groupID); _deleteGroupConversation(groupID);
final groupName = await _getGroupName(groupID); final groupName = await _getGroupName(groupID);
_needUpdate = NeedUpdate(groupID, UpdateType.groupDismissed, groupName); _needUpdate = NeedUpdate(groupID, UpdateType.groupDismissed, groupName);

View File

@ -44,12 +44,23 @@ class TIMUIKitConfig {
/// with a default value of `true`, and backward-compatibility. /// with a default value of `true`, and backward-compatibility.
final bool isPreloadMessagesAfterInit; final bool isPreloadMessagesAfterInit;
const TIMUIKitConfig( { ///
///
/// []true
final bool enableGroupCompositeAvatar;
///
/// []4
final int maxCompositeAvatarsCount;
const TIMUIKitConfig({
this.defaultAvatarAssetPath, this.defaultAvatarAssetPath,
this.showDesktopModalFunc, this.showDesktopModalFunc,
this.isPreloadMessagesAfterInit = true, this.isPreloadMessagesAfterInit = true,
this.defaultAvatarBorderRadius, this.defaultAvatarBorderRadius,
this.isCheckDiskStorageSpace = true, this.isCheckDiskStorageSpace = true,
this.isShowOnlineStatus = true, this.isShowOnlineStatus = true,
this.enableGroupCompositeAvatar = true,
this.maxCompositeAvatarsCount = 4,
}); });
} }

View File

@ -494,7 +494,8 @@ class _TIMUIKitConversationState extends TIMUIKitState<TIMUIKitConversation> {
? onlineStatus ? onlineStatus
: null, : null,
draftTimestamp: conversationItem.draftTimestamp, draftTimestamp: conversationItem.draftTimestamp,
convType: conversationItem.type), convType: conversationItem.type,
groupID: conversationItem.groupID),
onTap: () => onTapConvItem(conversationItem), onTap: () => onTapConvItem(conversationItem),
), ),
); );

View File

@ -35,6 +35,8 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget {
final V2TimUserStatus? onlineStatus; final V2TimUserStatus? onlineStatus;
final int? convType; final int? convType;
final bool isCurrent; final bool isCurrent;
// ID
final String? groupID;
TIMUIKitConversationItem({ TIMUIKitConversationItem({
Key? key, Key? key,
@ -51,6 +53,7 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget {
this.draftTimestamp, this.draftTimestamp,
this.lastMessageBuilder, this.lastMessageBuilder,
this.convType, this.convType,
this.groupID,
}) : super(key: key); }) : super(key: key);
Widget _getShowMsgWidget(BuildContext context) { Widget _getShowMsgWidget(BuildContext context) {
@ -138,8 +141,8 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget {
Container( Container(
padding: const EdgeInsets.only(top: 0, bottom: 2, right: 0), padding: const EdgeInsets.only(top: 0, bottom: 2, right: 0),
child: SizedBox( child: SizedBox(
width: isDesktopScreen ? 40 : 44, width: isDesktopScreen ? 40 : 48,
height: isDesktopScreen ? 40 : 44, height: isDesktopScreen ? 40 : 48,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
clipBehavior: Clip.none, clipBehavior: Clip.none,
@ -148,7 +151,8 @@ class TIMUIKitConversationItem extends TIMUIKitStatelessWidget {
onlineStatus: onlineStatus, onlineStatus: onlineStatus,
faceUrl: faceUrl, faceUrl: faceUrl,
showName: nickName, showName: nickName,
type: convType), type: convType,
groupID: groupID),
if (unreadCount != 0) if (unreadCount != 0)
Positioned( Positioned(
top: isDisturb ? -2.5 : -4.5, top: isDisturb ? -2.5 : -4.5,

View File

@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' 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'; 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'; 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/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.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_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 { class Avatar extends TIMUIKitStatelessWidget {
final String faceUrl; final String faceUrl;
@ -23,6 +28,26 @@ class Avatar extends TIMUIKitStatelessWidget {
final bool isCircle; // final bool isCircle; //
final TUISelfInfoViewModel selfInfoViewModel = final TUISelfInfoViewModel selfInfoViewModel =
serviceLocator<TUISelfInfoViewModel>(); 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( Avatar(
{Key? key, {Key? key,
@ -33,10 +58,205 @@ class Avatar extends TIMUIKitStatelessWidget {
this.isFromLocalAsset = false, this.isFromLocalAsset = false,
this.borderRadius, this.borderRadius,
this.isCircle = true, this.isCircle = true,
this.type = 1}) this.type = 1,
this.groupID})
: super(key: key); : 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) {
// 21
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 {
// 42x2
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() { Widget defaultAvatar() {
if (type == 1) { if (type == 1) {
return Image.asset( return Image.asset(
@ -61,7 +281,6 @@ class Avatar extends TIMUIKitStatelessWidget {
} }
} }
// final emptyAvatarBuilder = coreService.emptyAvatarBuilder;
if (faceUrl != "" && faceUrl.isNotEmpty) { if (faceUrl != "" && faceUrl.isNotEmpty) {
if (isFromLocalAsset) { if (isFromLocalAsset) {
return Image.asset( 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 getImageProvider() {
ImageProvider defaultAvatar() { ImageProvider defaultAvatar() {
if (type == 1) { if (type == 1) {
@ -145,8 +379,18 @@ class Avatar extends TIMUIKitStatelessWidget {
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme; final TUITheme theme = value.theme;
// isCircle参数决定borderRadius // isCircle参数决定borderRadius4
BorderRadius getAvatarBorderRadius() { 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) { if (isCircle) {
return BorderRadius.circular(200); // 使 return BorderRadius.circular(200); // 使
} }

View File

@ -99,6 +99,7 @@ class _RecentForwardListState extends TIMUIKitState<RecentForwardList> {
faceUrl: faceUrl, faceUrl: faceUrl,
showName: showName, showName: showName,
type: conversation.type, type: conversation.type,
groupID: conversation.groupID,
), ),
), ),
Expanded( Expanded(