flutter/lib/pages/chat/chat.dart
2025-09-17 15:32:18 +08:00

2427 lines
94 KiB
Dart
Raw 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 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/chat_json.dart';
import 'mock/emoj_json.dart';
class Chat extends StatefulWidget {
const Chat({super.key});
@override
State<Chat> createState() => _ChatState();
}
class _ChatState extends State<Chat> with SingleTickerProviderStateMixin {
final ImagePicker _picker = ImagePicker();
late final ChatDetailController controller;
// 接收参数
late final Rx<V2TimConversation> 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<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();
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 {
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<ChatController>().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<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?.toLogString());
// 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: () {
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) {
// 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)),
);
}
}
// 发送消息队列
Future<void> sendMessage(message) async {
// 待插入的消息
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);
}
// 不需要时间标签
// 消息类型
late final ImResult res;
res = await IMMessage().sendMessage(
msg: message,
toUserID: arguments.value.userID,
);
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<void> 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<void> _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<void> _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: 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: 'remark',
// child: Row(
// children: [
// Icon(Icons.edit, color: Colors.white, size: 18),
// SizedBox(width: 8),
// Text(
// '设置备注',
// style: TextStyle(color: Colors.white),
// ),
// ],
// ),
// ),
// PopupMenuItem<String>(
// 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<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),
),
],
),
),
// PopupMenuItem<String>(
// 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: 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;
}
}