/// 聊天模板 library; import 'package:flutter/material.dart'; import 'package:flutter/services.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_service.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 '../../behavior/custom_scroll_behavior.dart'; import '../../components/image_group.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 ChatGroup extends StatefulWidget { const ChatGroup({super.key}); @override State createState() => _ChatState(); } class _ChatState extends State with SingleTickerProviderStateMixin { late final ChatDetailController controller; // 接收参数 // Rx arguments = Get.arguments.obs; late final Rx arguments; late String selfUserId; // 聊天消息模块 final bool isNeedScrollBottom = true; // final RxList chatList = [].obs; bool isLoading = false; // 是否在加载中 bool hasMore = true; // 是否还有更多数据 bool _throttleFlag = false; // 滚动节流锁 // 表情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(); ScrollController emojController = ScrollController(); // 模拟开红包按钮动画 late AnimationController animController; // 创建一个从 0 到 π 的旋转动画 late Animation animTurns; // 初始化状态 @override void initState() { super.initState(); final arg = Get.arguments as V2TimConversation; arguments = arg.obs; controller = Get.put(ChatDetailController(userID: arguments.value.userID ?? '')); animController = AnimationController( vsync: this, duration: Duration(milliseconds: 500), ); animTurns = Tween(begin: 0, end: 3.1415926).animate(animController); cleanUnRead(); getUserId(); getMsgData(initFlag: true); // 编辑框获取焦点 editorFocusNode.addListener(() { if (editorFocusNode.hasFocus) { setState(() { toolbarEnable = false; }); scrollToBottom(); } }); // 滚动监听 chatController.addListener(() { if (_throttleFlag) return; if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) { _throttleFlag = true; getMsgData().then((_) { // 解锁 Future.delayed(Duration(milliseconds: 300), () { _throttleFlag = false; }); }); } }); } @override void dispose() { if (Get.isRegistered()) { Get.delete(); } // 更新会话列表数据 Get.find().getConversationList(); chatController.dispose(); emojController.dispose(); editorFocusNode.dispose(); animController.dispose(); super.dispose(); } // 设置好友备注 void setRemark() async { String remark = ''; await MyDialog.confirm( Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), TextField( onChanged: (value) => remark = value, maxLength: 16, maxLengthEnforcement: MaxLengthEnforcement.enforced, // 强制不能输入超过 decoration: InputDecoration( hintText: '请输入备注', filled: true, fillColor: const Color(0xFFF5F5F5), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Color(0xFFBE4EFF), width: 1), ), ), ) ], ), title: '设置备注', buttonText: '确认', cancelText: '取消', onConfirm: () async { // print('备注为:$remark'); final res = await ImService.instance.setFriendInfo(userID: arguments.value.userID!, friendRemark: remark); if (res.success) { // 刷新会话列表数据 // Get.find().getConversationList(); arguments.update((val) { val?.showName = remark; }); } else { print(res.desc); print(arguments.value.userID); MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } 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))); } } } void getUserId() async { final idRes = await ImService.instance.selfUserId(); if (idRes.success) { selfUserId = idRes.data; } } Future getMsgData({bool initFlag = false}) async { if (isLoading || !hasMore) return; // 正在加载 or 没有更多了 isLoading = true; // 获取最旧一条消息作为游标 V2TimMessage? lastRealMsg; for (var msg in controller.chatList.reversed) { if (msg.localCustomData != 'time_label') { lastRealMsg = msg; break; } } final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数 // 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))); } else { if (initFlag) { newMessages.insert(0, lastMsg!); } controller.updateChatListWithTimeLabels(newMessages); print('聊天数据加载成功'); } } else { MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } isLoading = false; } // 渲染聊天消息 List renderChatList() { List 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), ), ], ), )); } // 文本消息模板 else if (item.elemType == 1) { 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表情模板 else if (item.elemType == 4) { 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.customElem?.data}'), ), onLongPress: () { contextMenuDialog(); }, ), ), )); } // 图片模板 else if (item.elemType == 5) { List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; msgtpl.add(RenderChatItem( data: item, child: Ink( child: InkWell( overlayColor: WidgetStateProperty.all(Colors.transparent), child: ClipRRect( borderRadius: BorderRadius.circular(10.0), child: ImageGroup( images: imagePaths, width: 120, ), ), onLongPress: () { contextMenuDialog(); }, ), ), )); } // 视频模板 else if (item.elemType == 6) { msgtpl.add(RenderChatItem( data: item, child: Ink( child: InkWell( overlayColor: WidgetStateProperty.all(Colors.transparent), child: SizedBox( width: 90.0, child: Stack( alignment: Alignment.center, children: [ ClipRRect( borderRadius: BorderRadius.circular(10.0), child: Image.network( item.videoElem?.videoUrl ?? '', ), ), const Align( alignment: Alignment.center, child: Icon( Icons.play_circle, color: Colors.white, size: 30.0, ), ), ], ), ), onTap: () { MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); }, onLongPress: () { contextMenuDialog(); }, ), ), )); } // 语音模板 else if (item.elemType == 7) { List 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: 120.0, maxWidth: (item.soundElem?.duration)! / 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('${item.soundElem?.duration}'), ] : [ Text('${item.soundElem?.duration}'), const SizedBox( width: 5.0, ), const Icon(Icons.multitrack_audio), ], ), ), onTap: () { 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 == 0 && item.customElem?.desc == 'hongbao') { 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( padding: const EdgeInsets.all(10.0), child: Row( spacing: 10.0, children: [ Image.asset( 'assets/images/hbico.png', width: 32.0, fit: BoxFit.contain, ), Text(item.customElem?.data ?? '', style: const TextStyle(color: Colors.white, fontSize: 14.0)), ], ), ), 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(); }, ), ), )); } // 位置模板 else if (item.elemType == 9) { 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: [ 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 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((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); }, ), ); } }).toList(), ), ); }).toList(), ), ), ), ]; } // 选择功能列表 List 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: 100), () { if (chatController.hasClients) { chatController.animateTo( 0, // reverse: true 时滚动到底部是 offset: 0 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 { // 待插入的消息 List 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); } // 不需要时间标签 // final res = await IMMessage().sendText( // text: message['content'], // toUserID: arguments.value.userID, // ); // if (res.success && res.data != null) { // messagesToInsert.insert(0, res.data); // 加入消息本体 // controller.chatList.insertAll(0, messagesToInsert); // 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; }); 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大图 void handleGIFClick(gifpath) { // 消息队列 Map message = { 'id': Utils.uuid(), 'contentType': 4, 'isme': true, 'avatar': 'assets/images/avatar/img11.jpg', 'author': 'Andy', 'content': '', 'image': gifpath, 'video': '', }; sendMessage(message); } // 提交消息 void handleSubmit() { if (editorController.text.isEmpty) return; // 消息队列 Map message = { 'id': Utils.uuid(), 'contentType': 3, 'isme': true, 'avatar': 'assets/images/avatar/img11.jpg', 'author': 'Andy', 'content': editorController.text, 'image': '', 'video': '', }; sendMessage(message); editorController.clear(); } // 选择区操作 void handleChooseAction(key) { MyDialog.toast('$key'); switch (key) { case 'photo': // .... break; case 'camera': // .... break; case 'redpacket': sendRedPacketDialog(); break; } } /* ---------- { 弹窗功能模块 } ---------- */ // 红包弹窗 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() { 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: true, ); }, ); } /* ---------- { 其它功能模块 } ---------- */ @override Widget build(BuildContext context) { return Stack( fit: StackFit.expand, children: [ // 页面主体(聊天消息区/底部操作区) Scaffold( backgroundColor: Colors.grey[200], 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( value: 'remark', child: Row( children: [ Icon(Icons.edit, color: Colors.white, size: 18), SizedBox(width: 8), Text( '设置备注', style: TextStyle(color: Colors.white), ), ], ), ), PopupMenuItem( value: 'not', child: Row( children: [ Icon(Icons.do_not_disturb_on, color: Colors.white, size: 18), SizedBox(width: 8), Text( '设为免打扰', style: TextStyle(color: Colors.white), ), ], ), ), PopupMenuItem( value: 'report', child: Row( children: [ Icon(Icons.report, color: Colors.white, size: 18), SizedBox(width: 8), Text( '举报', style: TextStyle(color: Colors.white), ), ], ), ), PopupMenuItem( value: 'block', child: Row( children: [ Icon(Icons.block, color: Colors.white, size: 18), SizedBox(width: 8), Text( '拉黑', style: TextStyle(color: Colors.white), ), ], ), ), PopupMenuItem( value: 'foucs', child: Row( children: [ Icon(Icons.person_remove_alt_1, color: Colors.white, size: 18), SizedBox(width: 8), Text( '取消关注', style: TextStyle(color: Colors.white), ), ], ), ), ], ); if (selected != null) { switch (selected) { case 'remark': print('点击了备注'); setRemark(); break; case 'not': print('点击了免打扰'); break; case 'report': print('点击了举报'); break; case 'block': print('点击了拉黑'); break; case 'foucs': print('点击了取关'); break; } } }, ), ], ), body: Flex( direction: Axis.vertical, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 渲染聊天消息 Expanded( child: ScrollConfiguration( behavior: CustomScrollBehavior(), child: GestureDetector( child: Obx(() { return ListView( controller: chatController, reverse: true, padding: const EdgeInsets.all(10.0), children: renderChatList(), ); }), onTap: () { handleClickChatArea(); }, ), ), ), // 底部操作栏 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, color: const Color(0xFF3B3B3B), size: 30.0, ), onTap: () { setState(() { toolbarEnable = false; if (voiceBtnEnable) { voiceBtnEnable = false; editorFocusNode.requestFocus(); } else { voiceBtnEnable = 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) { return Container( margin: const EdgeInsets.only(bottom: 10.0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ !(data.isSelf ?? false) ? SizedBox( height: 35.0, width: 35.0, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20.0)), child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), ), ) : 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( data.friendRemark ?? data.nameCard ?? data.nickName ?? '未知昵称', 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: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), ), ) : 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; } }