flutter/lib/pages/chat/chat_no_friend.dart
2025-09-22 14:41:47 +08:00

2391 lines
96 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// 聊天模板
library;
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:loopin/IM/controller/chat_controller.dart';
import 'package:loopin/IM/controller/chat_detail_controller.dart';
import 'package:loopin/IM/im_message.dart';
import 'package:loopin/IM/im_result.dart';
import 'package:loopin/IM/im_service.dart';
import 'package:loopin/components/image_viewer.dart';
import 'package:loopin/components/network_or_asset_image.dart';
import 'package:loopin/components/preview_video.dart';
import 'package:loopin/models/conversation_type.dart';
import 'package:loopin/models/summary_type.dart';
import 'package:loopin/pages/chat/notify_controller/notify_no_friend_controller.dart';
import 'package:loopin/utils/audio_player_service.dart';
import 'package:loopin/utils/snapshot.dart';
import 'package:shirne_dialog/shirne_dialog.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import '../../styles/index.dart';
import '../../utils/index.dart';
import './components/redpacket.dart';
import './components/richtext.dart';
// import 'mock/chat_json.dart';
import 'mock/emoj_json.dart';
class ChatNoFriend extends StatefulWidget {
const ChatNoFriend({super.key});
@override
State<ChatNoFriend> createState() => _ChatNoFriendState();
}
class _ChatNoFriendState extends State<ChatNoFriend> with SingleTickerProviderStateMixin {
late final ChatDetailController controller;
// 接收参数
late final Rx<V2TimConversation> arguments;
late String selfUserId;
// 聊天消息模块
final bool isNeedScrollBottom = true;
final String _tips = '回复或关注对方之前,对方只能发送一条消息';
bool isLoading = false; // 是否在加载中
bool hasMore = true; // 是否还有更多数据
final RxBool _throttleFlag = false.obs; // 滚动节流锁
RxMap<String, bool> voicePlayingMap = <String, bool>{}.obs;
// 表情json
List emoJson = emotionData;
// 底部操作栏模块
TextEditingController editorController = TextEditingController();
FocusNode editorFocusNode = FocusNode();
bool voiceBtnEnable = false; // 语音按钮
bool voicePanelEnable = false; // 语音操作面板
bool voiceToTransfer = false; // 语音转文字中
int voiceType = 0; // 语音操作类型
Map voiceTypeMap = {
0: '按住 说话', // 按住说话
1: '松开 发送', // 松开发送
2: '松开 取消', // 松开取消(左滑)
3: '语音转文字', // 语音转文字(右滑)
};
bool toolbarEnable = false; // 显示表情/选择区域
int toolbarIndex = 0; // 0 表情 1 选择
double keyboardHeight = 157.6; // 键盘高度
List chooseOptions = [
{'key': 'photo', 'name': '相册', 'icon': 'assets/images/icon_photo.webp'},
{'key': 'camera', 'name': '拍摄', 'icon': 'assets/images/icon_camera.webp'},
{'key': 'location', 'name': '位置', 'icon': 'assets/images/icon_location.webp'},
{'key': 'redpacket', 'name': '红包', 'icon': 'assets/images/icon_hb.webp'},
];
// controller监听
// ScrollController chatController = ScrollController();
late ScrollController chatController;
ScrollController emojController = ScrollController();
// 模拟开红包按钮动画
late AnimationController animController;
// 创建一个从 0 到 π 的旋转动画
late Animation<double> animTurns;
// 初始化状态
@override
void initState() {
super.initState();
final arg = Get.arguments as V2TimConversation;
arguments = arg.obs;
controller = Get.find<ChatDetailController>();
chatController = controller.chatController;
animController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 500),
);
animTurns = Tween<double>(begin: 0, end: 3.1415926).animate(animController);
cleanUnRead();
getUserId();
isMyFriend();
getMsgData(initFlag: true);
// 编辑框获取焦点
editorFocusNode.addListener(() {
if (editorFocusNode.hasFocus) {
setState(() {
toolbarEnable = false;
});
controller.scrollToBottom();
}
});
// 滚动监听
// Future.delayed(Duration(milliseconds: 1000), () {
// });
chatController.addListener(() {
if (_throttleFlag.value) return;
if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) {
// if (chatController.position.pixels <= 50) {
_throttleFlag.value = true;
getMsgData().then((_) {
// 解锁
Future.delayed(Duration(milliseconds: 1000), () {
_throttleFlag.value = false;
});
});
}
});
}
@override
void dispose() {
if (Get.isRegistered<ChatDetailController>()) {
Get.delete<ChatDetailController>();
}
emojController.dispose();
editorFocusNode.dispose();
animController.dispose();
super.dispose();
}
// 设置好友备注
void setRemark() async {
print('不支持');
}
void isMyFriend() async {
final isFriendId = arguments.value.userID;
// final isfd = await ImService.instance.isMyFriend(isFriendId!, FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH);
// if (isfd.success) {
// controller.isFriend.value = isfd.data;
// print(isfd.data);
// } else {
// controller.isFriend.value = false;
// print(isfd.desc);
// }
/// 0不是好友也没有关注
/// 1你关注了对方单向
/// 2对方关注了你单向
/// 3互相关注双向好友
final isfd = await ImService.instance.checkFollowType(userIDList: [isFriendId!]);
if (isfd.success) {
controller.followType.value = isfd.data?.first.followType ?? 0;
if ([3].contains(controller.followType.value)) {
controller.isFriend.value = true;
} else {
controller.isFriend.value = false;
}
logger.i('当前聊天的关系为:${controller.followType.value}');
}
}
bool checkSend() {
if (controller.isFriend.value) {
// 是好友直接发
return true;
} else {
// 不是好友
if (arguments.value.lastMessage != null) {
// 最后一条消息是否我发的
// final isSelf = arguments.value.lastMessage!.isSelf;
final isSelf = controller.chatList.first.isSelf;
return isSelf == true ? false : true;
} else {
return true;
}
}
}
void cleanUnRead() async {
if ((arguments.value.unreadCount ?? 0) > 0) {
final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.value.conversationID);
if (!res.success) {
MyDialog.toast('清理未读异常:${res.desc}', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
}
logger.w('清理陌生人会话未读消息,准备刷新消息页的UI陌生人会话列表的UI');
// 是否从未读会话列表而来
if (Get.isRegistered<NotifyNoFriendController>()) {
final ctl = Get.find<NotifyNoFriendController>();
ctl.updateUnread(conversationID: arguments.value.conversationID);
}
}
}
void getUserId() async {
final idRes = await ImService.instance.selfUserId();
if (idRes.success) {
selfUserId = idRes.data;
}
}
Future<void> getMsgData({bool initFlag = false}) async {
if (isLoading || !hasMore) return; // 正在加载 or 没有更多了
isLoading = true;
// 获取最旧一条消息作为游标
V2TimMessage? lastRealMsg;
// for (var msg in controller.chatList.reversed) {
for (var msg in controller.chatList.reversed) {
if (msg.localCustomData != 'time_label') {
lastRealMsg = msg;
break;
}
}
final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数
// print(lastMsg?.toJson());
// final lastMsg = controller.chatList.isNotEmpty ? controller.chatList.last : arguments.lastMessage;
final res = await ImService.instance.getHistoryMessageList(
userID: arguments.value.userID,
lastMsg: lastMsg,
);
if (res.success) {
final newMessages = res.data ?? [];
if (newMessages.isEmpty) {
hasMore = false;
// MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
}
if (initFlag && lastMsg != null) {
newMessages.insert(0, lastMsg);
// controller.scrollToBottom();
}
controller.updateChatListWithTimeLabels(newMessages);
if (initFlag) {
// 初始化时滚到最底部
WidgetsBinding.instance.addPostFrameCallback((_) {
if (chatController.hasClients) {
// controller.scrollToBottom();
// final bottomPadding = MediaQuery.of(context).padding.bottom; // 底部安全区域高度
// chatController.jumpTo(chatController.position.maxScrollExtent); // 60为底部操作栏高度
chatController.jumpTo(0);
}
});
}
print('聊天数据加载成功');
} else {
MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
}
isLoading = false;
}
// 渲染聊天消息
List<Widget> renderChatList() {
List<Widget> msgtpl = [];
for (var item in controller.chatList) {
// 时间提示,公告提示
if (item.localCustomData == 'time_label') {
msgtpl.add(Container(
margin: const EdgeInsets.only(bottom: 15.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.customElem!.data ?? '',
style: TextStyle(color: Colors.grey[600], fontSize: 12.0),
),
],
),
));
}
// 写入记录的tips
else if (item.cloudCustomData == 'tips') {
msgtpl.add(
Container(
margin: const EdgeInsets.only(bottom: 15.0),
width: double.infinity,
child: Text(
item.textElem!.text ?? '',
style: TextStyle(color: Colors.grey[600], fontSize: 12.0),
softWrap: true,
maxLines: 5,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
);
}
// 文本消息模板=1
else if (item.elemType == 1 && item.cloudCustomData != 'tips') {
msgtpl.add(
RenderChatItem(
data: item,
child: Ink(
decoration: BoxDecoration(
color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B),
borderRadius: BorderRadius.circular(10.0),
),
child: InkWell(
overlayColor: WidgetStateProperty.all(Colors.transparent),
borderRadius: BorderRadius.circular(10.0),
child: Container(
padding: const EdgeInsets.all(10.0),
child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话
),
onLongPress: () {
contextMenuDialog();
},
),
),
),
);
}
// gif表情模板=8
else if (item.elemType == 8) {
msgtpl.add(RenderChatItem(
data: item,
child: Ink(
child: InkWell(
overlayColor: WidgetStateProperty.all(Colors.transparent),
child: Container(
constraints: const BoxConstraints(
maxHeight: 100.0,
maxWidth: 100.0,
),
// child: Image.asset('assets/images/emotion/${item.faceElem?.data}'),
child: Image.asset('${item.faceElem?.data}'),
),
onLongPress: () {
contextMenuDialog();
},
),
),
));
}
// 图片模板=3
else if (item.elemType == 3) {
// List<String> imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? [];
final originImage = item.imageElem?.imageList?.firstWhere((e) => e?.type == 0 && e?.url != null, orElse: () => null);
List<String> imagePaths = originImage != null ? [originImage.url!] : [];
msgtpl.add(RenderChatItem(
data: item,
child: Ink(
child: InkWell(
onTap: () {
// 预览图片
Get.to(() => ImageViewer(
images: [imagePaths.first],
index: 0,
));
},
overlayColor: WidgetStateProperty.all(Colors.transparent),
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
// child: ImageGroup(
// images: imagePaths,
// width: 120,
// ),
child: Image.network(
imagePaths.first,
width: 120,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
// controller.scrollToBottom();
return child; // 加载完成,显示图片
}
return Container(
width: 120,
height: 240,
color: Colors.grey[300],
alignment: Alignment.center,
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
alignment: Alignment.center,
child: Icon(Icons.broken_image, color: Colors.grey, size: 40),
);
},
),
),
onLongPress: () {
contextMenuDialog();
},
),
),
));
}
// 视频模板=5
else if (item.elemType == 5) {
// print(item.videoElem!.toLogString());
msgtpl.add(RenderChatItem(
data: item,
child: Ink(
child: InkWell(
overlayColor: WidgetStateProperty.all(Colors.transparent),
child: SizedBox(
width: 120.0,
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: NetworkOrAssetImage(
imageUrl: item.videoElem?.snapshotUrl ?? '',
width: 120,
),
),
const Align(
alignment: Alignment.center,
child: Icon(
Icons.play_circle,
color: Colors.white,
size: 30.0,
),
),
],
),
),
onTap: () {
showGeneralDialog(
context: context,
// barrierDismissible: true,
barrierColor: Colors.black.withAlpha((1.0 * 255).round()),
pageBuilder: (_, __, ___) {
return SafeArea(
child: PreviewVideo(
videoUrl: item.videoElem?.videoUrl ?? '',
width: item.videoElem?.snapshotWidth?.toDouble(),
height: item.videoElem?.snapshotHeight?.toDouble(),
),
);
},
transitionBuilder: (_, anim, __, child) {
return FadeTransition(opacity: anim, child: child);
},
transitionDuration: const Duration(milliseconds: 200),
);
},
onLongPress: () {
contextMenuDialog();
},
),
),
));
}
// 语音模板=4
else if (item.elemType == 4) {
final durationMs = item.soundElem?.duration ?? 0;
final durationSeconds = (durationMs / 1000).round();
final maxWidth = (durationSeconds / 60 * 230).clamp(80.0, 230.0);
List<Widget> audiobody = [
Ink(
decoration: BoxDecoration(
color: !(item.isSelf ?? false) ? const Color(0xFFFFFFFF) : const Color(0xFF89E45B),
borderRadius: BorderRadius.circular(10.0),
),
child: InkWell(
overlayColor: WidgetStateProperty.all(Colors.transparent),
borderRadius: BorderRadius.circular(10.0),
child: Container(
padding: const EdgeInsets.all(10.0),
constraints: BoxConstraints(
maxWidth: maxWidth,
// maxWidth: (item.soundElem!.duration! / 1000) / 60 * 230,
),
child: Row(
mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end,
children: !(item.isSelf ?? false)
? [
const Icon(Icons.multitrack_audio),
const SizedBox(
width: 5.0,
),
Text('$durationSeconds"'),
]
: [
Text('$durationSeconds"'),
const SizedBox(
width: 5.0,
),
const Icon(Icons.multitrack_audio),
],
),
),
onTap: () async {
final locUrl = item.soundElem?.path ?? '';
final netUrl = item.soundElem?.url ?? '';
final msgId = item.id ?? '${item.timestamp ?? 0}';
logger.w('本地地址$locUrl');
logger.w('网络地址$netUrl');
if (locUrl.isNotEmpty) {
voicePlayingMap[msgId] = true;
await AudioPlayerService().playLocal(locUrl);
voicePlayingMap[msgId] = false;
} else if (netUrl.isNotEmpty) {
voicePlayingMap[msgId] = true;
await AudioPlayerService().playNetwork(netUrl);
voicePlayingMap[msgId] = false;
} else {
MyDialog.toast('音频文件已过期', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
}
},
onLongPress: () {
contextMenuDialog();
},
),
),
const SizedBox(
width: 5.0,
),
// FStyle.badge(0, isdot: true),
];
if (item.isSelf ?? false) {
// 内容反转
audiobody = audiobody.reversed.toList();
} else {
audiobody = audiobody;
}
msgtpl.add(RenderChatItem(
data: item,
child: Row(
mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end,
children: audiobody,
)));
}
// 分享团购商品
else if (item.elemType == 2 && item.cloudCustomData == SummaryType.shareTuangou) {
// final makeJson = jsonEncode({
// "price": shopObj['price'],
// "title": shopObj['name'],
// "url": shopObj['pic'],
// "sell": Utils.graceNumber(int.parse(shopObj['sales'] ?? '0')),
// "goodsId": shopObj['id'],
// "userID": Get.find<ImUserInfoController>().userID.value,
// });
final obj = jsonDecode(item.customElem!.data!);
logger.e(obj);
final goodsId = obj['goodsId'];
final userID = obj['userID'];
final url = obj['url'];
final title = obj['title'];
final price = obj['price'];
final sell = Utils.graceNumber(int.tryParse(obj['sell'])!);
msgtpl.add(RenderChatItem(
data: item,
child: GestureDetector(
onTap: () {
// 这里带上分享人的ID
Get.toNamed('/goods', arguments: {'goodsId': goodsId, 'userID': userID});
},
child: Container(
width: 160,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15.0), boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(5),
offset: Offset(0.0, 1.0),
blurRadius: 1.0,
spreadRadius: 0.0,
),
]),
child: Column(
children: [
NetworkOrAssetImage(
imageUrl: url,
width: 160.0,
),
Container(
padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 5.0,
children: [
Text(
'$title',
style: TextStyle(fontSize: 14.0, height: 1.2),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text.rich(
overflow: TextOverflow.ellipsis,
maxLines: 1,
TextSpan(style: TextStyle(color: Colors.red, fontSize: 12.0, fontWeight: FontWeight.w700, fontFamily: 'Arial'), children: [
TextSpan(text: '¥'),
TextSpan(
text: '$price',
style: TextStyle(
fontSize: 14.0,
)),
]),
),
),
SizedBox(
width: 5,
),
Text(
'已售$sell件',
style: TextStyle(color: Colors.grey, fontSize: 10.0),
),
],
),
],
),
)
],
),
),
),
));
}
// 分享短视频
else if (item.elemType == 2 && item.cloudCustomData == SummaryType.shareVideo) {
/// {imgUrl,videoUrl,width,height}
final obj = jsonDecode(item.customElem!.data!);
logger.e(obj);
final videoId = obj['videoId'];
final videoUrl = obj['videoUrl'];
final imgUrl = obj['imgUrl'];
final width = obj['width'] as num;
final height = obj['height'] as num;
final isHorizontal = width > height;
msgtpl.add(RenderChatItem(
data: item,
child: Ink(
child: InkWell(
overlayColor: WidgetStateProperty.all(Colors.transparent),
child: SizedBox(
width: 120.0,
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: Container(
width: 120,
height: 240,
color: Colors.black,
child: NetworkOrAssetImage(
imageUrl: imgUrl,
fit: isHorizontal ? BoxFit.contain : BoxFit.cover,
placeholderAsset: 'assets/images/bk.jpg',
),
),
),
const Align(
alignment: Alignment.center,
child: Icon(
Icons.play_circle,
color: Colors.white,
size: 30.0,
),
),
],
),
),
onTap: () {
Get.toNamed('/videoDetail', arguments: {'videoId': videoId});
// showGeneralDialog(
// context: context,
// barrierColor: Colors.black.withAlpha((1.0 * 255).round()),
// pageBuilder: (_, __, ___) {
// return SafeArea(
// bottom: true,
// child: Padding(
// padding: const EdgeInsets.only(bottom: 4),
// child: PreviewVideo(
// videoUrl: videoUrl,
// width: width.toDouble(),
// height: height.toDouble(),
// ),
// ),
// );
// },
// transitionBuilder: (_, anim, __, child) {
// return FadeTransition(opacity: anim, child: child);
// },
// transitionDuration: const Duration(milliseconds: 200),
// );
},
// onLongPress: () {
// contextMenuDialog();
// },
),
),
));
}
// 红包模板=自定义=2
else if (item.elemType == 2 && item.cloudCustomData == SummaryType.hongbao) {
final obj = jsonDecode(item.customElem!.data!);
final open = obj['open'] ?? false;
final remark = obj['remark'];
// final maxNum = obj['maxNum'];
msgtpl.add(RenderChatItem(
data: item,
child: Ink(
decoration: BoxDecoration(
color: const Color(0xFFFF7F43),
borderRadius: BorderRadius.circular(10.0),
),
child: InkWell(
overlayColor: WidgetStateProperty.all(Colors.transparent),
borderRadius: BorderRadius.circular(10.0),
child: Container(
constraints: const BoxConstraints(
maxWidth: 210.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 210.0,
padding: const EdgeInsets.all(10.0),
child: Row(
children: <Widget>[
open
? Icon(Icons.check_circle, size: 32.0, color: Colors.white70)
: Image.asset(
'assets/images/hbico.png',
width: 32.0,
fit: BoxFit.contain,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'$remark',
style: const TextStyle(color: Colors.white, fontSize: 14.0),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 10.0),
padding: const EdgeInsets.symmetric(vertical: 5.0),
width: double.infinity,
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.white30, width: .5))),
child: const Text(
'红包',
style: TextStyle(color: Colors.white70, fontSize: 11.0),
),
),
],
),
),
onTap: () {
receiveRedPacketDialog(item);
},
onLongPress: () {
contextMenuDialog();
},
),
),
));
}
// 位置模板=7
else if (item.elemType == 7) {
msgtpl.add(RenderChatItem(
data: item,
child: Ink(
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
borderRadius: BorderRadius.circular(10.0),
),
child: InkWell(
// splashColor: Colors.transparent,
overlayColor: WidgetStateProperty.all(Colors.transparent),
borderRadius: BorderRadius.circular(10.0),
child: Container(
constraints: const BoxConstraints(
maxWidth: 210.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10.0,
vertical: 5.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.locationElem?.desc ?? '位置信息异常',
overflow: TextOverflow.ellipsis,
),
Text(
"${item.locationElem?.latitude},${item.locationElem?.longitude}",
style: const TextStyle(color: Colors.grey, fontSize: 12.0),
overflow: TextOverflow.ellipsis,
),
],
),
),
ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(10.0)),
child: Image.asset('assets/images/map.jpg', width: 210.0, height: 70.0, fit: BoxFit.cover),
)
],
),
),
onTap: () {
MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
},
onLongPress: () {
contextMenuDialog();
},
),
),
));
}
}
return msgtpl;
}
// 表情列表集合
List<Widget> renderEmojWidget() {
return [
// Tab切换
Container(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
child: Row(
children: emoJson.map((item) {
return InkWell(
child: Container(
margin: const EdgeInsets.all(5.0),
alignment: Alignment.center,
height: 40.0,
width: 40.0,
decoration: BoxDecoration(color: item['selected'] ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(5.0)),
child: item['index'] == 0
? Text(
item['pathLabel'],
style: const TextStyle(fontSize: 22.0),
)
: Image.asset(item['pathLabel'], height: 24.0, width: 24.0, fit: BoxFit.cover),
),
onTap: () {
handleEmojTab(item['index']);
},
);
}).toList(),
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.grey[200],
border: const Border(top: BorderSide(color: Colors.black54, width: .1)),
),
child: ListView(
controller: emojController,
padding: const EdgeInsets.all(10.0),
children: emoJson.map((item) {
return Visibility(
visible: item['selected'],
child: GridView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
// 横轴元素个数
crossAxisCount: item['type'] == 'emoj' ? 8 : 5,
// 纵轴间距
mainAxisSpacing: 5.0,
// 横轴间距
crossAxisSpacing: 5.0,
// 子组件宽高比例
childAspectRatio: 1,
),
children: item['nodes'].map<Widget>((emoj) {
if (item['type'] == 'emoj') {
return Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(5.0),
child: Container(
alignment: Alignment.center,
height: 40.0,
width: 40.0,
child: Text(
emoj,
style: const TextStyle(fontSize: 24.0),
),
),
onTap: () {
handleEmojClick(emoj);
},
),
);
} else {
return Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(5.0),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(5.0),
height: 68.0,
width: 68.0,
child: Image.asset(emoj),
),
onTap: () {
handleGIFClick(emoj, item['index']);
},
),
);
}
}).toList(),
),
);
}).toList(),
),
),
),
];
}
// 选择功能列表
List<Widget> renderChooseWidget() {
return [
Expanded(
child: Container(
padding: const EdgeInsets.fromLTRB(30.0, 35.0, 30.0, 15.0),
decoration: BoxDecoration(
color: Colors.grey[200],
border: const Border(top: BorderSide(color: Colors.black38, width: .1)),
),
child: GridView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
// 横轴元素个数
crossAxisCount: 4,
// 纵轴间距
mainAxisSpacing: 30.0,
// 横轴间距
crossAxisSpacing: 25.0,
// 子组件宽高比例
childAspectRatio: .8,
),
children: chooseOptions.map((item) {
return Column(
children: [
Expanded(
child: Material(
type: MaterialType.transparency,
child: Ink(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15.0),
),
child: InkWell(
borderRadius: BorderRadius.circular(15.0),
child: Image.asset(item['icon'], height: 40.0, fit: BoxFit.cover),
onTap: () {
handleChooseAction(item['key']);
},
),
),
),
),
const SizedBox(height: 5.0),
Text(
item['name'],
style: const TextStyle(color: Colors.black87, fontSize: 12.0),
)
],
);
}).toList(),
),
),
),
];
}
/* ---------- { 聊天消息模块 } ---------- */
// 聊天消息滚动到底部
// void scrollToBottom() async {
// chatList = await fetchChatList();
// chatController.animateTo(isNeedScrollBottom ? 0 : chatController.position.maxScrollExtent,
// duration: const Duration(milliseconds: 200), curve: Curves.easeIn);
// }
// void scrollToBottom() {
// Future.delayed(Duration(milliseconds: 300), () {
// if (chatController.hasClients) {
// chatController.animateTo(
// // 0, // reverse: true 时滚动到底部是 offset: 0
// chatController.position.maxScrollExtent,
// duration: Duration(milliseconds: 300),
// curve: Curves.easeOut,
// );
// }
// });
// }
// 点击消息区域
void handleClickChatArea() {
hideKeyboard();
setState(() {
toolbarEnable = false;
});
}
/* ---------- { 底部Toolbar模块 } ---------- */
// 光标处插入内容
void insertTextAtCursor(String html) {
var editorNotifier = editorController.value; // The current value stored in this notifier.
var start = editorNotifier.selection.baseOffset;
var end = editorNotifier.selection.extentOffset;
if (editorNotifier.selection.isValid) {
String newText = '';
if (editorNotifier.selection.isCollapsed) {
if (end > 0) {
newText += editorNotifier.text.substring(0, end);
}
newText += html;
if (editorNotifier.text.length > end) {
newText += editorNotifier.text.substring(end, editorNotifier.text.length);
}
} else {
newText = editorNotifier.text.replaceRange(start, end, html);
end = start;
}
editorController.value =
editorNotifier.copyWith(text: newText, selection: editorNotifier.selection.copyWith(baseOffset: end + html.length, extentOffset: end + html.length));
} else {
editorController.value = TextEditingValue(
text: html,
selection: TextSelection.fromPosition(TextPosition(offset: html.length)),
);
}
}
// 发送消息队列
void sendMessage(message) async {
final canSend = checkSend();
if (!canSend) {
final baseStyle = MyDialog.theme.toastStyle?.top();
MyDialog.toast(
'对方未回关或回复,只能发送一条消息',
icon: const Icon(Icons.check_circle),
duration: Duration(milliseconds: 5000),
style: baseStyle?.copyWith(
backgroundColor: Colors.red.withAlpha(200),
),
);
print('禁止发送$canSend');
return;
}
// 待插入的消息
List<V2TimMessage> messagesToInsert = [];
V2TimMessage? lastRealMsg;
for (var msg in controller.chatList) {
if (msg.localCustomData != 'time_label') {
lastRealMsg = msg;
break;
}
}
// 如果有数据,检测时间,是否需要插入伪消息
if (lastRealMsg != null &&
needInsertTimeLabel(
(lastRealMsg.timestamp ?? 0) * 1000, // 转为毫秒级
DateTime.now().millisecondsSinceEpoch,
)) {
// 消息时间间隔超过3分钟插入伪消息
final showLabel = Utils.formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000);
final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId);
messagesToInsert.add(resMsg.data);
} else {
// 没数据的时候直接插入伪消息
final showLabel = Utils.formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000);
final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId);
messagesToInsert.add(resMsg.data);
}
// 不需要时间标签
// 消息类型message['type']
late final ImResult res;
res = await IMMessage().sendMessage(
msg: message,
toUserID: arguments.value.userID,
cloudCustomData: canSend == true ? ConversationType.noFriend.name : '',
);
if (res.success && res.data != null) {
messagesToInsert.insert(0, res.data); // 加入消息本体
// messagesToInsert.add(res.data); // 加入消息本体
controller.chatList.insertAll(0, messagesToInsert);
// controller.chatList.addAll(messagesToInsert);
controller.scrollToBottom();
print('发送成功');
} else {
print('消息发送失败: ${res.code} - ${res.desc}');
}
}
bool needInsertTimeLabel(int lastTimestamp, int newTimestamp, {int interval = 3 * 60}) {
return (newTimestamp - lastTimestamp) > interval * 1000;
}
// 隐藏键盘
void hideKeyboard() {
if (editorFocusNode.hasFocus) {
editorFocusNode.unfocus();
}
}
// 表情/选择切换
void handleEmojChooseState(index) {
hideKeyboard();
setState(() {
toolbarEnable = true;
toolbarIndex = index;
voiceBtnEnable = false;
});
controller.scrollToBottom();
}
// 表情Tab切换
void handleEmojTab(index) {
var emols = emoJson;
for (var i = 0, len = emols.length; i < len; i++) {
emols[i]['selected'] = false;
}
emols[index]['selected'] = true;
setState(() {
emoJson = emols;
});
emojController.jumpTo(0);
}
// 点击表情插入到输入框
void handleEmojClick(emoj) {
insertTextAtCursor(emoj);
}
// 点击Gif大图发送=8
void handleGIFClick(gifpath, index) async {
// 消息队列
// Map message = {
// 'contentType': 8,
// 'content': gifpath,
// };
final res = await IMMessage().createFaceMessage(data: gifpath, index: index);
if (res.success) {
sendMessage(res.data?.messageInfo);
}
}
// 发送文本消息=1
void handleSubmit() async {
if (editorController.text.isEmpty) return;
// 消息队列
// Map message = {
// 'contentType': 1,
// 'content': editorController.text,
// };
final res = await IMMessage().createTextMessage(text: editorController.text);
if (res.success) {
sendMessage(res.data?.messageInfo);
editorController.clear();
}
}
// 发送图片消息=3
void sendImage(imgPath) async {
final resImg = await IMMessage().createImageMessage(imagePath: imgPath);
if (resImg.success) {
sendMessage(resImg.data?.messageInfo);
}
}
// 发送视频消息=5
void sendVideo(videoFilePath, type, duration, snapshotPath) async {
final resImg = await IMMessage().createVideoMessage(
videoFilePath: videoFilePath,
type: type,
duration: duration,
snapshotPath: snapshotPath,
);
if (resImg.success) {
sendMessage(resImg.data?.messageInfo);
}
}
// 底部操作蓝选择区操作
void handleChooseAction(key) {
MyDialog.toast('$key');
switch (key) {
case 'photo':
// ....
pickFile(context);
break;
case 'camera':
// ....
break;
case 'redpacket':
sendRedPacketDialog();
break;
}
}
///从相册选取图片/视频
void pickFile(BuildContext context) async {
final pickedAssets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
textDelegate: const AssetPickerTextDelegate(),
pathNameBuilder: (AssetPathEntity album) {
return Utils.translateAlbumName(album);
},
maxAssets: 5,
requestType: RequestType.common,
filterOptions: FilterOptionGroup(
imageOption: const FilterOption(),
videoOption: const FilterOption(
durationConstraint: DurationConstraint(
max: Duration(seconds: 120),
),
),
),
),
);
if (pickedAssets != null && pickedAssets.isNotEmpty) {
for (final asset in pickedAssets) {
switch (asset.type) {
case AssetType.image:
print("选中了图片:${asset.title}");
var file = await asset.file;
if (file != null) {
var fileSizeInBytes = await file.length();
var sizeInMB = fileSizeInBytes / (1024 * 1024);
if (sizeInMB > 28) {
MyDialog.toast('图片大小不能超过28MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
} else {
print("图片合法,大小:$sizeInMB MB");
// 执行发送逻辑
sendImage(file.path);
}
}
break;
case AssetType.video:
print("选中了视频:${asset.title}");
var file = await asset.file;
if (file != null) {
var fileSizeInBytes = await file.length();
var sizeInMB = fileSizeInBytes / (1024 * 1024);
if (sizeInMB > 100) {
MyDialog.toast('视频大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
} else {
print("视频合法,大小:$sizeInMB MB");
// 执行发送逻辑
var snapshot = await generateVideoThumbnail(file.path);
String? mimeType = await asset.mimeTypeAsync;
String vdType = mimeType?.split('/').last ?? 'mp4';
print(vdType);
sendVideo(file.path, vdType, asset.duration, snapshot);
}
}
break;
default:
print("不支持的类型:${asset.type}");
}
}
// final asset = pickedAssets.first;
// final file = await asset.file; // 获取实际文件
// if (file != null) {
// final fileSizeInBytes = await file.length();
// final sizeInMB = fileSizeInBytes / (1024 * 1024);
// if (sizeInMB > 100) {
// MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200)));
// } else {
// print("图片合法,大小:$sizeInMB MB");
// //走upload(file)上传图片拿到url地址
// // file;
// }
// }
}
}
/* ---------- { 弹窗功能模块 } ---------- */
// 红包弹窗
void receiveRedPacketDialog(data) {
showDialog(
context: context,
builder: (context) {
return Material(
type: MaterialType.transparency,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 50.0),
padding: const EdgeInsets.symmetric(vertical: 50.0),
decoration: const BoxDecoration(
color: Color(0xFFFF7F43),
borderRadius: BorderRadius.all(Radius.circular(12.0)),
),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5.0),
child: Image.asset(data['avatar'], height: 40.0, width: 40.0, fit: BoxFit.cover),
),
const SizedBox(
height: 5.0,
),
Text(
data['author'],
style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600),
),
Text(
data['content'],
style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0),
),
SizedBox(
height: 100.0,
),
AnimatedBuilder(
animation: animTurns,
builder: (context, child) {
return Transform(
transform: Matrix4.rotationY(animTurns.value),
alignment: Alignment.center,
child: FilledButton(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)),
padding: WidgetStateProperty.all(EdgeInsets.zero),
minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)),
shape: WidgetStateProperty.all(const CircleBorder()),
elevation: WidgetStateProperty.all(3.0),
),
child: Text(
'',
style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0),
),
onPressed: () {
// 开始动画
animController.repeat();
// 模拟开红包逻辑1 秒后停止动画
Future.delayed(Duration(seconds: 1), () {
animController.stop();
animController.reset();
Get.back();
});
},
),
);
},
),
],
),
),
GestureDetector(
child: Container(
margin: const EdgeInsets.only(top: 20.0),
height: 30.0,
width: 30.0,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 1.5),
borderRadius: BorderRadius.circular(50.0),
),
child: const Icon(
Icons.close_outlined,
color: Colors.white,
size: 18.0,
),
),
onTap: () {
Navigator.of(context).pop();
},
)
],
));
});
}
// 长按消息菜单
void contextMenuDialog() {
return;
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(vertical: 5.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)),
children: [
SimpleDialogOption(
child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('复制')),
onPressed: () {},
),
SimpleDialogOption(
child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('发送给朋友')),
onPressed: () {},
),
SimpleDialogOption(
child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('收藏')),
onPressed: () {},
),
SimpleDialogOption(
child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('删除')),
onPressed: () {},
),
],
);
},
);
}
// 发群红包弹窗
void sendRedPacketDialog() {
showModalBottomSheet(
backgroundColor: Colors.grey[50],
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))),
showDragHandle: true,
clipBehavior: Clip.hardEdge,
isScrollControlled: true, // 屏幕最大高度
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height - 180, // 自定义最大高度
),
context: context,
builder: (context) {
return RedPacket(
flag: false,
);
},
);
}
/* ---------- { 其它功能模块 } ---------- */
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// 页面主体(聊天消息区/底部操作区)
Scaffold(
backgroundColor: Colors.grey[200],
resizeToAvoidBottomInset: true, // 启用键盘自动避让
appBar: AppBar(
centerTitle: true,
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios_rounded,
size: 20.0,
),
onPressed: () {
Get.back();
},
),
titleSpacing: 1.0,
title: Obx(() {
return Text(
// '${arguments['title']}',
'${arguments.value.showName}',
style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'),
);
}),
flexibleSpace: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFBE4EFF), Color(0xFF1DFFC7)],
)),
),
actions: [
// IconButton(
// icon: const Icon(
// Icons.more_horiz,
// color: Colors.white,
// ),
// onPressed: () async {
// final paddingTop = MediaQuery.of(Get.context!).padding.top;
// final selected = await showMenu(
// context: Get.context!,
// position: RelativeRect.fromLTRB(
// double.infinity,
// kToolbarHeight + paddingTop - 12,
// 8,
// double.infinity,
// ),
// color: FStyle.primaryColor,
// elevation: 8,
// items: [
// PopupMenuItem<String>(
// value: 'report',
// child: Row(
// children: [
// Icon(Icons.report, color: Colors.white, size: 18),
// SizedBox(width: 8),
// Text(
// '举报',
// style: TextStyle(color: Colors.white),
// ),
// ],
// ),
// ),
// PopupMenuItem<String>(
// value: 'block',
// child: Row(
// children: [
// Icon(Icons.block, color: Colors.white, size: 18),
// SizedBox(width: 8),
// Text(
// '拉黑',
// style: TextStyle(color: Colors.white),
// ),
// ],
// ),
// ),
// ],
// );
// if (selected != null) {
// switch (selected) {
// case 'report':
// print('点击了举报');
// break;
// case 'block':
// print('点击了拉黑');
// break;
// }
// }
// },
// ),
],
),
body: Flex(
direction: Axis.vertical,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 渲染聊天消息
// Expanded(
// child: ScrollConfiguration(
// behavior: CustomScrollBehavior(),
// child: GestureDetector(
// child: Obx(() {
// return ListView(
// reverse: true,
// controller: chatController,
// padding: const EdgeInsets.all(10.0),
// children: renderChatList(),
// );
// }),
// onTap: () {
// handleClickChatArea();
// },
// ),
// ),
// ),
// 聊天内容
// Expanded(
// child: GestureDetector(
// behavior: HitTestBehavior.opaque,
// onTap: handleClickChatArea,
// child: LayoutBuilder(
// builder: (context, constraints) {
// return Obx(() {
// // final tipWidget = Builder(builder: (context) => Text(tips));
// final msgWidgets = renderChatList().reversed.toList();
// // if (controller.isFriend.value) {}
// return ListView(
// controller: chatController,
// reverse: true,
// padding: const EdgeInsets.all(10.0),
// children: [
// ConstrainedBox(
// constraints: BoxConstraints(
// minHeight: constraints.maxHeight - 20,
// ),
// child: Column(
// mainAxisSize: MainAxisSize.min,
// children: msgWidgets,
// ),
// ),
// ],
// );
// });
// },
// ),
// ),
// ),
// 聊天内容
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: handleClickChatArea,
child: LayoutBuilder(
builder: (context, constraints) {
return Obx(() {
final showTipWidget = controller.followType.value;
const double tipHeight = 50; // 提示横幅固定高度
final msgWidgets = renderChatList().reversed.toList();
return Stack(
children: [
ListView(
controller: chatController,
reverse: true,
padding: EdgeInsets.only(
top: [1, 2].contains(showTipWidget) ? tipHeight + 10 : 0, // 动态预留顶部空间
left: 10,
right: 10,
bottom: 10,
),
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight - ([1, 2].contains(showTipWidget) ? tipHeight : 0),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: msgWidgets,
),
),
],
),
if ([1, 2].contains(showTipWidget))
Positioned(
top: 0,
left: 0,
right: 0,
height: tipHeight, // 固定高度
child: Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(
Icons.error_outline,
color: FStyle.primaryColor,
size: 20,
),
SizedBox(
width: 10,
),
Expanded(
child: Text(
'对方未回复或互关前仅可发送一条消息',
style: const TextStyle(color: Colors.black, fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
Visibility(
visible: [2].contains(showTipWidget),
child: TextButton(
style: TextButton.styleFrom(
backgroundColor: FStyle.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onPressed: () async {
// 回关
final ctl = Get.find<ChatController>();
final chatRes = await ImService.instance.followUser(userIDList: [arguments.value.userID!]);
if (chatRes.success) {
controller.isFriend.value = true;
controller.followType.value = 3;
final res = await ImService.instance.getConversation(conversationID: 'c2c_${arguments.value.userID}');
if (res.success) {
V2TimConversation conversation = res.data;
if (conversation.conversationGroupList?.isNotEmpty ?? false) {
await ImService.instance.deleteConversationsFromGroup(
groupName: conversation.conversationGroupList!.first!,
conversationIDList: [conversation.conversationID],
);
await ctl.updateNoFriendMenu();
// 跳转到chat地址销毁当前页面
Get.offAllNamed(
'/chat',
arguments: arguments.value,
predicate: (route) {
// 清理栈,只保留 `/`
return route.settings.name == '/';
},
);
}
}
}
// 回关
// final res = await ImService.instance.followUser(userIDList: [arguments.value.userID!]);
// if (res.success) {
// controller.isFriend.value = true;
// controller.followType.value = 3;
// if (arguments.value.conversationGroupList?.isNotEmpty == true) {
// //把会话数据从陌生人分组移除
// final ctl = Get.find<ChatController>();
// await ctl.removeNoFriend(conversationID: arguments.value.conversationID);
// ctl.updateNoFriendMenu();
// // 跳转到chat地址销毁当前页面
// Get.offAllNamed(
// '/chat',
// arguments: arguments.value,
// predicate: (route) {
// // 清理栈,只保留 `/`
// return route.settings.name == '/';
// },
// );
// }
// }
},
child: const Text('回关', style: TextStyle(color: Colors.white)),
),
)
],
),
),
),
],
);
});
},
),
),
),
// 底部操作栏
Container(
color: Colors.grey[100],
child: SafeArea(
bottom: true,
child: Container(
decoration: BoxDecoration(
color: Colors.grey[100],
border: const Border(top: BorderSide(color: Colors.black38, width: .1)),
),
child: Column(
children: [
// 输入框编辑器模块
Container(
padding: const EdgeInsets.all(10.0),
child: Row(
children: [
InkWell(
child: Icon(
// voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined,
Icons.keyboard_outlined,
color: const Color(0xFF3B3B3B),
size: 30.0,
),
onTap: () {
setState(() {
toolbarEnable = false;
if (editorFocusNode.hasFocus) {
editorFocusNode.unfocus();
} else {
editorFocusNode.requestFocus();
}
if (toolbarEnable) {
// voiceBtnEnable = false;
// editorFocusNode.requestFocus();
// editorFocusNode.requestFocus();
} else {
// voiceBtnEnable = true;
// toolbarEnable = true;
// editorFocusNode.unfocus();
}
});
},
),
const SizedBox(
width: 10.0,
),
Expanded(
child: Container(
constraints: const BoxConstraints(minHeight: 40.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
),
child: Stack(
children: [
// 输入框
Offstage(
offstage: voiceBtnEnable,
child: TextField(
decoration: const InputDecoration(
isDense: true,
hoverColor: Colors.transparent,
contentPadding: EdgeInsets.all(8.0),
border: OutlineInputBorder(borderSide: BorderSide.none),
),
style: const TextStyle(
fontSize: 16.0,
),
maxLines: null,
controller: editorController,
focusNode: editorFocusNode,
cursorColor: const Color(0xFF07C160),
onChanged: (value) {},
),
),
// 语音
Offstage(
offstage: !voiceBtnEnable,
child: GestureDetector(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
),
alignment: Alignment.center,
height: 40.0,
width: double.infinity,
child: Text(
voiceTypeMap[voiceType],
style: const TextStyle(fontSize: 15.0),
),
),
onPanStart: (details) {
setState(() {
voiceType = 1;
voicePanelEnable = true;
});
},
onPanUpdate: (details) {
Offset pos = details.globalPosition;
double swipeY = MediaQuery.of(context).size.height - 120;
double swipeX = MediaQuery.of(context).size.width / 2 + 50;
setState(() {
if (pos.dy >= swipeY) {
voiceType = 1; // 松开发送
} else if (pos.dy < swipeY && pos.dx < swipeX) {
voiceType = 2; // 左滑松开取消
} else if (pos.dy < swipeY && pos.dx >= swipeX) {
voiceType = 3; // 右滑语音转文字
}
});
},
onPanEnd: (details) {
// print('停止录音');
setState(() {
switch (voiceType) {
case 1:
MyDialog.toast('发送录音文件');
voicePanelEnable = false;
break;
case 2:
MyDialog.toast('取消发送');
voicePanelEnable = false;
break;
case 3:
MyDialog.toast('语音转文字');
voicePanelEnable = true;
voiceToTransfer = true;
break;
}
voiceType = 0;
});
},
),
),
],
),
),
),
const SizedBox(
width: 10.0,
),
// InkWell(
// child: const Icon(
// Icons.add_reaction_rounded,
// color: Color(0xFF3B3B3B),
// size: 30.0,
// ),
// onTap: () {
// handleEmojChooseState(0);
// },
// ),
// const SizedBox(
// width: 8.0,
// ),
// InkWell(
// child: const Icon(
// Icons.add,
// color: Color(0xFF3B3B3B),
// size: 30.0,
// ),
// onTap: () {
// handleEmojChooseState(1);
// },
// ),
const SizedBox(
width: 8.0,
),
InkWell(
child: Container(
height: 25.0,
width: 25.0,
decoration: BoxDecoration(
color: const Color(0xFF07C160),
borderRadius: BorderRadius.circular(20.0),
),
child: const Icon(
Icons.arrow_upward,
color: Colors.white,
size: 20.0,
),
),
onTap: () {
handleSubmit();
},
),
],
),
),
// 表情+选择模块
Visibility(
visible: toolbarEnable,
child: SizedBox(
height: keyboardHeight,
child: Column(
children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(),
),
),
)
],
),
),
),
)
],
),
),
// 录音主体(按住说话/松开取消/语音转文本)
IgnorePointer(
ignoring: false,
child: Visibility(
visible: voicePanelEnable,
child: Material(
color: const Color(0xDD1B1B1B),
child: Stack(
children: [
// 取消发送+语音转文字
Positioned(
bottom: 120,
left: 30,
right: 30,
child: Visibility(
visible: !voiceToTransfer,
child: Column(
crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
// 语音动画层
Stack(
alignment: Alignment.bottomCenter,
children: [
AnimatedContainer(
duration: Duration(milliseconds: 200),
height: 70.0,
width: voiceType == 2 ? 70.0 : 200.0,
decoration: BoxDecoration(
color: voiceType == 2 ? Colors.red : Color(0xFF89E45B),
borderRadius: BorderRadius.circular(15.0),
),
clipBehavior: Clip.antiAlias,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('assets/images/voice_waves.gif', height: 23.0, width: voiceType == 2 ? 30.0 : 70.0, fit: BoxFit.cover)
],
),
),
RotatedBox(
quarterTurns: 0,
child: CustomPaint(painter: ArrowShape(arrowColor: voiceType == 2 ? Colors.red : Color(0xFF89E45B), arrowSize: 10.0)),
)
],
),
const SizedBox(
height: 50.0,
),
// 操作项
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 取消发送
Container(
height: 60.0,
width: 60.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50.0),
color: voiceType == 2 ? Colors.red : Colors.black38,
),
child: Icon(
Icons.close,
color: Colors.white54,
),
),
// 语音转文字
Container(
height: 60.0,
width: 60.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50.0),
color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38,
),
child: Icon(
Icons.translate,
color: Colors.white54,
),
),
],
),
],
),
),
),
// 语音转文字(识别结果状态)
Positioned(
bottom: 120,
left: 30,
right: 30,
child: Visibility(
visible: voiceToTransfer,
child: Column(
children: [
// 提示结果
Stack(
children: [
Container(
height: 100.0,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(15.0),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outlined,
color: Colors.white,
),
Text(
'未识别到文字。',
style: TextStyle(color: Colors.white),
),
],
),
),
Positioned(
right: 35.0,
bottom: 1,
child: RotatedBox(
quarterTurns: 0,
child: CustomPaint(painter: ArrowShape(arrowColor: Colors.red, arrowSize: 10.0)),
)),
],
),
const SizedBox(
height: 50.0,
),
// 操作项
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
child: Container(
height: 60.0,
width: 60.0,
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.undo,
color: Colors.white54,
),
Text(
'取消',
style: TextStyle(color: Colors.white70),
)
],
),
),
onTap: () {
setState(() {
voicePanelEnable = false;
voiceToTransfer = false;
});
},
),
GestureDetector(
child: Container(
height: 60.0,
width: 100.0,
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.graphic_eq_rounded,
color: Colors.white54,
),
Text(
'发送原语音',
style: TextStyle(color: Colors.white70),
)
],
),
),
onTap: () {},
),
GestureDetector(
child: Container(
height: 60.0,
width: 60.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50.0),
color: Colors.white12,
),
child: const Icon(
Icons.check,
color: Colors.white12,
),
),
onTap: () {},
),
],
),
],
),
),
),
// 提示文字(操作状态)
Positioned(
bottom: 120,
left: 0,
width: MediaQuery.of(context).size.width,
child: Visibility(
visible: !voiceToTransfer,
child: Align(
child: Text(
voiceTypeMap[voiceType],
style: const TextStyle(color: Colors.white70),
),
),
),
),
// 背景
Align(
alignment: Alignment.bottomCenter,
child: Visibility(
visible: !voiceToTransfer,
child: Image.asset('assets/images/voice_bg.webp', width: double.infinity, height: 100.0, fit: BoxFit.fill),
),
),
// 背景图标
Positioned(
bottom: 25,
left: 0,
width: MediaQuery.of(context).size.width,
child: Visibility(
visible: !voiceToTransfer,
child: const Align(
child: Icon(
Icons.graphic_eq_rounded,
color: Colors.black54,
),
),
),
),
],
),
),
),
)
],
);
}
}
// 渲染聊天消息公共部分
class RenderChatItem extends StatelessWidget {
const RenderChatItem({
super.key,
required this.data,
required this.child,
});
final V2TimMessage data; // 消息数据
final Widget? child; // 消息体
// 设置箭头颜色
// Color arrowColor(data) {
// Color color = Colors.transparent;
// if ([8].contains(data.elemType)) {
// // 红包箭头颜色
// color = const Color(0xFFFFA52F);
// } else if ([9].contains(data.elemType)) {
// // 位置箭头颜色
// color = const Color(0xFFFFFFFF);
// } else {
// color = !data['isme'] ? const Color(0xFFFFFFFF) : const Color(0xFF9543FF);
// }
// return color;
// }
@override
Widget build(BuildContext context) {
String? displayName = (data.friendRemark?.isNotEmpty ?? false)
? data.friendRemark
: (data.nameCard?.isNotEmpty ?? false)
? data.nameCard
: (data.nickName?.isNotEmpty ?? false)
? data.nickName
: '未知昵称';
return Container(
margin: const EdgeInsets.only(bottom: 10.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
!(data.isSelf ?? false)
? GestureDetector(
onTap: () {
// 头像点击事件
logger.e("点击了头像");
Get.toNamed('/vloger', arguments: {'memberId': data.sender});
// 你可以在这里处理点击事件,例如打开用户详情页
},
child: SizedBox(
height: 35.0,
width: 35.0,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20.0)),
child: NetworkOrAssetImage(imageUrl: data.faceUrl),
),
),
)
: const SizedBox.shrink(),
Expanded(
child: Padding(
padding: !(data.isSelf ?? false) ? const EdgeInsets.only(left: 10.0, right: 40.0) : const EdgeInsets.only(left: 40.0, right: 10.0),
child: Column(
crossAxisAlignment: !(data.isSelf ?? false) ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
// Text(
// displayName ?? '未知昵称',
// style: const TextStyle(color: Colors.grey, fontSize: 12.0),
// ),
// const SizedBox(
// height: 3.0,
// ),
Stack(
children: [
// 气泡箭头
/* Visibility(
// 显示箭头(消息+语音+红包+位置)
visible: [3, 7, 8, 9].contains(data['contentType']),
child: Positioned(
left: !data['isme'] ? 1 : null,
right: data['isme'] ? 1 : null,
top: 20.0,
child: RotatedBox(
quarterTurns: !data['isme'] ? 1 : -1,
child: CustomPaint(painter: ArrowShape(arrowColor: arrowColor(data))),
)
),
), */
Container(
child: child,
),
],
),
],
),
),
),
data.isSelf ?? false
? SizedBox(
height: 35.0,
width: 35.0,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20.0)),
child: NetworkOrAssetImage(imageUrl: data.faceUrl),
),
)
: const SizedBox.shrink(),
],
),
);
}
}
// 绘制气泡箭头
class ArrowShape extends CustomPainter {
ArrowShape({
required this.arrowColor,
this.arrowSize = 7,
});
final Color arrowColor; // 箭头颜色
final double arrowSize; // 箭头大小
@override
void paint(Canvas canvas, Size size) {
var paint = Paint()..color = arrowColor;
var path = Path();
path.lineTo(-arrowSize, 0);
path.lineTo(0, arrowSize);
path.lineTo(arrowSize, 0);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}