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/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);

View File

@ -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,
});
}

View File

@ -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),
),
);

View File

@ -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,

View File

@ -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) {
// 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() {
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参数决定borderRadius4
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); // 使
}

View File

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