/// 群聊模板 library; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.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/summary_type.dart'; import 'package:loopin/utils/audio_player_service.dart'; import 'package:loopin/utils/snapshot.dart'; import 'package:loopin/utils/voice_service.dart'; import 'package:mime/mime.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:video_player/video_player.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/emoj_json.dart'; class ChatGroup extends StatefulWidget { const ChatGroup({super.key}); @override State createState() => _ChatGroupState(); } class _ChatGroupState extends State with SingleTickerProviderStateMixin { final ImagePicker _picker = ImagePicker(); late final ChatDetailController controller; // 接收参数 final V2TimConversation arguments = Get.arguments; late String selfUserId; // 聊天消息模块 final bool isNeedScrollBottom = true; bool isLoading = false; // 是否在加载中 bool hasMore = true; // 是否还有更多数据 final RxBool _throttleFlag = false.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 animTurns; // 初始化状态 @override void initState() { super.initState(); controller = Get.find(); chatController = controller.chatController; 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; }); 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()) { Get.delete(); } emojController.dispose(); editorFocusNode.dispose(); animController.dispose(); super.dispose(); } void cleanUnRead() async { if ((arguments.unreadCount ?? 0) > 0) { final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.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.lastMessage; // 如果找不到,就用传入的参数 logger.w(lastMsg?.toLogString()); final res = await ImService.instance.getHistoryMessageList( groupID: arguments.groupID, lastMsg: lastMsg, ); if (res.success) { final newMessages = res.data ?? []; if (newMessages.isEmpty) { hasMore = false; } if (initFlag && lastMsg != null) { newMessages.insert(0, lastMsg); } controller.updateChatListWithTimeLabels(newMessages); if (initFlag) { // 初始化时滚到最底部 WidgetsBinding.instance.addPostFrameCallback((_) { if (chatController.hasClients) { chatController.jumpTo(0); } }); } logger.w('群聊消息加载成功'); } 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), ), ], ), )); } // 文本消息模板=1 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表情模板=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 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 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 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: () { final locUrl = item.soundElem?.path ?? ''; final netUrl = item.soundElem?.url ?? ''; if (locUrl.isNotEmpty) { AudioPlayerService().playNetwork(locUrl); } else if (netUrl.isNotEmpty) { AudioPlayerService().playLocal(netUrl); } 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) { //price,title,url,sell 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: [ 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: [ 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, item['index']); }, ), ); } }).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: 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)), ); } } // 发送消息队列 Future 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); } // 不需要时间标签 // 消息类型 late final ImResult res; res = await IMMessage().sendMessage( msg: message, toUserID: arguments.groupID, ); 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(); } } // 发红包消息 void sendHongbao(date) async { final amount = date['amount']; //用户输入的金额 final remark = date['remark']; //用户输入的留言 final maxNum = date['maxNum']; //红包数量 // 先检测可用余额 final makeJson = jsonEncode({ "amount": amount, "remark": remark, "maxNum": maxNum, "open": false, }); final res = await IMMessage().createCustomMessage(data: makeJson); if (res.success && (res.data != null)) { final custMsg = res.data!.messageInfo; custMsg!.cloudCustomData = SummaryType.hongbao; sendMessage(res.data!.messageInfo); Get.back(); } } // 发送图片消息=3 void sendImage(imgPath) async { final resImg = await IMMessage().createImageMessage(imagePath: imgPath); if (resImg.success) { sendMessage(resImg.data?.messageInfo); } } // 发送语音消息=4 void sendVoiceMsg() async { final fileMap = await VoiceService().stopRecording(); if (fileMap != null) { final res = await IMMessage().createSoundMessage( soundPath: fileMap['path'], duration: fileMap['duration'], ); if (res.success && res.data != null) { sendMessage(res.data!.messageInfo); } else { MyDialog.toast('创建语音消息失败'); } } else { MyDialog.toast('语音限制1-60秒'); } } // 发送视频消息=5 void sendVideo(videoFilePath, type, duration, snapshotPath) async { final instance = MyDialog.loading('处理中'); final resImg = await IMMessage().createVideoMessage( videoFilePath: videoFilePath, type: type, duration: duration, snapshotPath: snapshotPath, ); if (resImg.success) { await sendMessage(resImg.data?.messageInfo); instance.close(); } } // 拍摄 Future showPicker(BuildContext context) async { showModalBottomSheet( context: context, backgroundColor: Colors.white, // 透明底色,这样圆角才能生效 builder: (context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( onTap: () async { Navigator.pop(context); await _pickPhoto(); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon(Icons.photo), SizedBox(width: 8), Text("拍摄照片"), ], ), ), ), InkWell( onTap: () async { Navigator.pop(context); await _pickVideo(); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon(Icons.videocam), SizedBox(width: 8), Text("拍摄视频"), ], ), ), ), const Divider(height: 1), InkWell( onTap: () => Navigator.pop(context), child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ // Icon(Icons.close), // SizedBox(width: 8), Text("取消"), ], ), ), ), ], ), ); }, ); } /// 拍照片 Future _pickPhoto() async { final XFile? photo = await _picker.pickImage( source: ImageSource.camera, maxWidth: 1920, maxHeight: 1080, imageQuality: 90, ); if (photo != null) { logger.w("照片路径: ${photo.path}"); sendImage(photo.path); } } /// 拍视频 Future _pickVideo() async { final XFile? video = await _picker.pickVideo( source: ImageSource.camera, maxDuration: const Duration(minutes: 1), ); if (video == null) return; logger.w("视频路径: ${video.path}"); // 获取首帧图 final snapshot = await generateVideoThumbnail(video.path) ?? ''; // 获取视频时长 final controller = VideoPlayerController.file(File(video.path)); await controller.initialize(); final duration = controller.value.duration.inSeconds; await controller.dispose(); // mimeType final mimeType = lookupMimeType(video.path) ?? 'video/mp4'; sendVideo(video.path, mimeType, duration, snapshot); } // 底部操作蓝选择区操作 void handleChooseAction(key) { // MyDialog.toast('$key'); switch (key) { case 'photo': // .... pickFile(context); break; case 'camera': // .... showPicker(context); 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 > 28) { MyDialog.toast('图片大小不能超过28MB', 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(V2TimMessage data) { showDialog( context: context, builder: (context) { final obj = jsonDecode(data.customElem!.data!); final amount = obj['amount']; final remark = obj['remark']; final open = obj['open'] ?? false; 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, horizontal: 20.0), decoration: const BoxDecoration( color: Color(0xFFFF7F43), borderRadius: BorderRadius.all(Radius.circular(12.0)), ), child: Column( children: [ ClipRRect( borderRadius: BorderRadius.circular(5.0), child: NetworkOrAssetImage( imageUrl: data.senderProfile?.faceUrl, ), ), const SizedBox( height: 5.0, ), Text( '${data.senderProfile?.nickName}的红包', style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600), ), SizedBox(height: 10), Text( amount, style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), ), SizedBox(height: 20.0), Text( remark, style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), ), SizedBox( height: 100.0, ), if (open == false && data.isSelf == false) 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: () async { // 点击开红包,开始动画 animController.repeat(); // 执行抢红包结果查询,(群)展示抢红包人员信息,单不用管 // 执行消费红包动作 //-------- // 成功后修改消息体 obj['open'] = true; //成功标记为true data.customElem!.data = jsonEncode(obj); ImService.instance.modifyMessage(message: data); // 模拟开红包逻辑,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: false, onSend: (date) { sendHongbao(date); }); }, ); } /* ---------- { 其它功能模块 } ---------- */ @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: Text( '${arguments.showName}', style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false, ), 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('点击了备注'); 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: GestureDetector( behavior: HitTestBehavior.opaque, onTap: handleClickChatArea, child: LayoutBuilder( builder: (context, constraints) { return Obx(() { final msgWidgets = renderChatList().reversed.toList(); return ListView( controller: chatController, reverse: true, padding: const EdgeInsets.all(10.0), children: [ ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight - 40, ), child: Column( mainAxisSize: MainAxisSize.min, children: msgWidgets, ), ), ], ); }); }, ), ), ), // 底部操作栏 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) async { // 开始录音 final res = await VoiceService().startRecording(); if (res) { setState(() { voiceType = 1; voicePanelEnable = true; }); } else { MyDialog.toast('未获得麦克风权限'); } }, 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('发送录音文件'); sendVoiceMsg(); voicePanelEnable = false; break; case 2: // MyDialog.toast('取消发送'); VoiceService().cancelRecording; 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: 160, left: 30, right: 30, child: Visibility( visible: !voiceToTransfer, child: Column( // crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ // 语音动画层 Stack( alignment: Alignment.bottomCenter, children: [ AnimatedContainer( duration: Duration(milliseconds: 200), height: 70.0, // width: voiceType == 2 ? 70.0 : 200.0, width: 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.center, 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.center, 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; } }