You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

395 lines
15 KiB

import 'package:dating_touchme_app/im/im_manager.dart';
import 'package:dating_touchme_app/pages/message/chat_page.dart';
import 'package:flutter/material.dart';
import 'package:dating_touchme_app/generated/assets.dart';
import 'package:get/get.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
import 'package:im_flutter_sdk/im_flutter_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../controller/message/conversation_controller.dart';
import '../../widget/message/emoji_text_widget.dart';
import '../../config/emoji_config.dart';
class ConversationTab extends StatefulWidget {
const ConversationTab({super.key});
@override
State<ConversationTab> createState() => _ConversationTabState();
}
class _ConversationTabState extends State<ConversationTab>
with AutomaticKeepAliveClientMixin {
final ConversationController controller = Get.find<ConversationController>();
@override
Widget build(BuildContext context) {
super.build(context);
return Column(
children: [
// 聊天列表
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(controller.errorMessage.value),
ElevatedButton(
onPressed: () => controller.refreshConversations(),
child: const Text('重试'),
),
],
),
);
}
// 直接使用 Obx 监听 conversations 和 filterType,避免 FutureBuilder 重建导致的闪烁
return Obx(() {
final filteredConversations = controller.conversations;
if (filteredConversations.isEmpty) {
return Center(
child: Text(
controller.filterType.value == FilterType.none
? '暂无会话'
: '暂无符合条件的会话',
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.only(top: 8),
itemCount: filteredConversations.length,
itemBuilder: (context, index) {
final conversation = filteredConversations[index];
return BuildConversationItem(conversation: conversation);
},
);
});
}),
),
],
);
}
@override
bool get wantKeepAlive => true;
}
class BuildConversationItem extends StatefulWidget {
final EMConversation conversation;
const BuildConversationItem({super.key, required this.conversation});
@override
State<BuildConversationItem> createState() => _BuildConversationItemState();
}
class _BuildConversationItemState extends State<BuildConversationItem> {
late EMConversation conversation;
bool isOnline = false;
late var cachedUserInfo;
final ConversationController controller = Get.find<ConversationController>();
@override
void initState() {
super.initState();
conversation = widget.conversation;
cachedUserInfo = controller.getCachedUserInfo(conversation.id);
IMManager.instance.getUserPresenceStatus(conversation.id).then((e){
isOnline = e == true;
setState(() {
});
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<ExtendedUserInfo?>(
future: cachedUserInfo != null
? Future.value(cachedUserInfo)
: controller.loadContact(conversation.id),
initialData: cachedUserInfo,
builder: (context, userSnapshot) {
final ExtendedUserInfo? userInfo = userSnapshot.data;
return FutureBuilder<EMMessage?>(
future: controller.lastMessage(conversation),
builder: (context, messageSnapshot) {
final EMMessage? message = messageSnapshot.data;
return FutureBuilder<int>(
future: controller.getUnreadCount(conversation),
builder: (context, unreadSnapshot) {
final int unreadCount = unreadSnapshot.data ?? 0;
final double screenWidth = MediaQuery.of(context).size.width;
final Widget cellContent = Builder(
builder: (cellContext) => GestureDetector(
onTap: () async {
TDSwipeCellInherited.of(cellContext)?.cellClick();
Get.to(ChatPage(userId: conversation.id));
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
margin: const EdgeInsets.only(
bottom: 8,
left: 16,
right: 16,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
color: Colors.grey[300],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(28),
child: (userInfo?.avatarUrl != null && userInfo!.avatarUrl!.isNotEmpty)
? CachedNetworkImage(
imageUrl: userInfo.avatarUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
placeholder: (context, url) => Image.asset(
Assets.imagesUserAvatar,
width: 56,
height: 56,
fit: BoxFit.cover,
),
errorWidget: (context, url, error) => Image.asset(
Assets.imagesUserAvatar,
width: 56,
height: 56,
fit: BoxFit.cover,
),
)
: Image.asset(
Assets.imagesUserAvatar,
width: 56,
height: 56,
fit: BoxFit.cover,
),
),
),
if(isOnline) Positioned(
bottom: 0,
right: 0,
child: Container(
width: 11.w,
height: 11.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(11.w)),
color: const Color.fromRGBO(43, 255, 191, 1)
),
),
)
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
userInfo?.nickName ?? conversation.id, // 如果没有昵称,显示用户ID
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
),
Text(
controller.formatMessageTime(
message?.serverTime ?? 0,
),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _buildLastMessageContent(
controller.getLastMessageContent(message),
message,
),
),
if (unreadCount > 0)
TDBadge(TDBadgeType.message, count: unreadCount.toString(), maxCount: '99')
],
),
],
),
),
],
),
),
),
);
return TDSwipeCell(
slidableKey: ValueKey('conversation_${conversation.id}'),
groupTag: 'conversation_swipe_group',
right: TDSwipeCellPanel(
extentRatio: 72 / screenWidth,
children: [
TDSwipeCellAction(
backgroundColor: TDTheme.of(context).errorColor6,
label: '删除',
onPressed: (actionContext) async {
final success = await controller.deleteConversation(
conversation.id,
);
if (!success) {
Get.snackbar('提示', '删除会话失败');
}
},
),
],
),
cell: cellContent,
);
},
);
},
);
},
);
}
// 构建最后一条消息内容(支持表情显示)
Widget _buildLastMessageContent(String content, EMMessage? message) {
// 检查是否是发送失败的消息
final isFailed = message != null &&
message.direction == MessageDirection.SEND &&
message.status == MessageStatus.FAIL;
// 检查是否包含表情
final containsEmoji = EmojiTextHelper.containsEmoji(content);
if (containsEmoji && !isFailed) {
// 如果包含表情且不是失败消息,使用自定义的单行表情文本 widget
return _buildSingleLineEmojiText(
content,
TextStyle(
fontSize: 14.sp,
color: Colors.grey,
),
);
} else {
// 如果不包含表情或是失败消息,使用普通 Text 显示
return Text(
content,
style: TextStyle(
fontSize: 14.sp,
color: isFailed
? Color.fromRGBO(248, 85, 66, 1) // 发送失败时显示红色
: Colors.grey,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}
}
// 构建单行表情文本(支持省略)
Widget _buildSingleLineEmojiText(String text, TextStyle textStyle) {
final RegExp emojiRegex = RegExp(r'\[emoji:(\d+)\]');
final List<InlineSpan> spans = [];
int lastMatchEnd = 0;
final matches = emojiRegex.allMatches(text);
for (final match in matches) {
// 添加表情之前的文本
if (match.start > lastMatchEnd) {
final textPart = text.substring(lastMatchEnd, match.start);
spans.add(TextSpan(text: textPart, style: textStyle));
}
// 添加表情图片(使用 WidgetSpan)
final emojiId = match.group(1);
if (emojiId != null) {
final emoji = EmojiConfig.getEmojiById(emojiId);
if (emoji != null) {
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 2.w),
child: Image.asset(
emoji.path,
width: 16.w,
height: 16.w,
fit: BoxFit.contain,
),
),
));
} else {
// 如果表情不存在,显示原始文本
spans.add(TextSpan(text: match.group(0)!, style: textStyle));
}
}
lastMatchEnd = match.end;
}
// 添加剩余的文本
if (lastMatchEnd < text.length) {
final textPart = text.substring(lastMatchEnd);
spans.add(TextSpan(text: textPart, style: textStyle));
}
return RichText(
text: TextSpan(children: spans),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
}