|
|
|
@ -1,5 +1,8 @@ |
|
|
|
import 'package:dating_touchme_app/extension/ex_widget.dart'; |
|
|
|
import 'package:extended_text/extended_text.dart'; |
|
|
|
import 'package:extended_text_field/extended_text_field.dart'; |
|
|
|
import 'package:flutter/material.dart'; |
|
|
|
import 'package:flutter/services.dart'; |
|
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
|
|
|
|
|
|
import '../../generated/assets.dart'; |
|
|
|
@ -13,7 +16,7 @@ class ChatInputBar extends StatefulWidget { |
|
|
|
final ValueChanged<List<String>>? onImageSelected; |
|
|
|
final Function(String filePath, int seconds)? onVoiceRecorded; |
|
|
|
final VoidCallback? onVoiceCall; // 语音通话回调 |
|
|
|
final VoidCallback? onVideoCall; // 视频通话回调 |
|
|
|
final Future<void> Function()? onVideoCall; // 视频通话回调 |
|
|
|
final VoidCallback? onGiftTap; // 礼物按钮回调 |
|
|
|
|
|
|
|
const ChatInputBar({ |
|
|
|
@ -28,6 +31,14 @@ class ChatInputBar extends StatefulWidget { |
|
|
|
|
|
|
|
@override |
|
|
|
State<ChatInputBar> createState() => _ChatInputBarState(); |
|
|
|
|
|
|
|
// 静态方法:通过 key 关闭面板 |
|
|
|
static void closePanels(GlobalKey? key) { |
|
|
|
final state = key?.currentState; |
|
|
|
if (state != null && state is _ChatInputBarState) { |
|
|
|
state.closeAllPanels(); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
@ -106,6 +117,11 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 公开方法:从外部关闭所有面板 |
|
|
|
void closeAllPanels() { |
|
|
|
_closeAllPanels(); |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
void initState() { |
|
|
|
super.initState(); |
|
|
|
@ -137,63 +153,6 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
setState(() {}); // 刷新显示 |
|
|
|
} |
|
|
|
|
|
|
|
/// 构建输入框内容(文本+表情) |
|
|
|
List<Widget> _buildInputContentWidgets() { |
|
|
|
final List<Widget> widgets = []; |
|
|
|
final text = _textController.text; |
|
|
|
final RegExp emojiRegex = RegExp(r'\[emoji:(\d+)\]'); |
|
|
|
|
|
|
|
int lastMatchEnd = 0; |
|
|
|
final matches = emojiRegex.allMatches(text); |
|
|
|
|
|
|
|
for (final match in matches) { |
|
|
|
// 添加表情之前的文本 |
|
|
|
if (match.start > lastMatchEnd) { |
|
|
|
final textPart = text.substring(lastMatchEnd, match.start); |
|
|
|
widgets.add( |
|
|
|
Text( |
|
|
|
textPart, |
|
|
|
style: TextStyle(fontSize: 14.sp, color: Colors.black), |
|
|
|
), |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
// 添加表情图片 |
|
|
|
final emojiId = match.group(1); |
|
|
|
if (emojiId != null) { |
|
|
|
final emoji = EmojiConfig.getEmojiById(emojiId); |
|
|
|
if (emoji != null) { |
|
|
|
widgets.add( |
|
|
|
Padding( |
|
|
|
padding: EdgeInsets.symmetric(horizontal: 2.w), |
|
|
|
child: Image.asset( |
|
|
|
emoji.path, |
|
|
|
width: 24.w, |
|
|
|
height: 24.w, |
|
|
|
fit: BoxFit.contain, |
|
|
|
), |
|
|
|
), |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
lastMatchEnd = match.end; |
|
|
|
} |
|
|
|
|
|
|
|
// 添加剩余的文本 |
|
|
|
if (lastMatchEnd < text.length) { |
|
|
|
final textPart = text.substring(lastMatchEnd); |
|
|
|
widgets.add( |
|
|
|
Text( |
|
|
|
textPart, |
|
|
|
style: TextStyle(fontSize: 14.sp, color: Colors.black), |
|
|
|
), |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
return widgets; |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
Widget build(BuildContext context) { |
|
|
|
return Column( |
|
|
|
@ -218,7 +177,7 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
child: Stack( |
|
|
|
children: [ |
|
|
|
// 真实的输入框 |
|
|
|
TextField( |
|
|
|
ExtendedTextField( |
|
|
|
controller: _textController, |
|
|
|
focusNode: _focusNode, |
|
|
|
decoration: InputDecoration( |
|
|
|
@ -228,32 +187,23 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
fontSize: 14.sp, |
|
|
|
color: Colors.grey, |
|
|
|
), |
|
|
|
contentPadding: EdgeInsets.only( |
|
|
|
bottom: 10 |
|
|
|
), |
|
|
|
), |
|
|
|
inputFormatters: [ |
|
|
|
// 可以添加其他格式化器,但不要添加过滤Unicode的规则 |
|
|
|
FilteringTextInputFormatter.deny(RegExp(r'[\u200B]')), // 仅示例:过滤零宽空格 |
|
|
|
], |
|
|
|
specialTextSpanBuilder: MySpecialTextSpanBuilder(), |
|
|
|
style: TextStyle( |
|
|
|
fontSize: 14.sp, |
|
|
|
color: _textController.text.contains('[emoji:') |
|
|
|
? Colors.transparent |
|
|
|
: Colors.black, |
|
|
|
color: Colors.black, // 文字始终显示为黑色,specialTextSpanBuilder 会处理表情 |
|
|
|
), |
|
|
|
onChanged: (value) { |
|
|
|
setState(() {}); // 刷新以更新表情显示 |
|
|
|
}, |
|
|
|
), |
|
|
|
// 表情显示层 |
|
|
|
if (_textController.text.contains('[emoji:')) |
|
|
|
Positioned.fill( |
|
|
|
child: IgnorePointer( |
|
|
|
child: Align( |
|
|
|
alignment: Alignment.centerLeft, |
|
|
|
child: SingleChildScrollView( |
|
|
|
scrollDirection: Axis.horizontal, |
|
|
|
child: Row( |
|
|
|
children: _buildInputContentWidgets(), |
|
|
|
), |
|
|
|
), |
|
|
|
), |
|
|
|
), |
|
|
|
), |
|
|
|
], |
|
|
|
), |
|
|
|
), |
|
|
|
@ -291,7 +241,7 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
width: 24.w, |
|
|
|
height: 24.w, |
|
|
|
).onTap(_toggleVoiceOptions), |
|
|
|
// 语音通话按钮(暂时注释) |
|
|
|
// 语音通话按钮(暂时隐藏) |
|
|
|
// Image.asset( |
|
|
|
// Assets.imagesSendCall, |
|
|
|
// width: 24.w, |
|
|
|
@ -299,13 +249,13 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
// ).onTap(() { |
|
|
|
// widget.onVoiceCall?.call(); |
|
|
|
// }), |
|
|
|
// 视频通话按钮(暂时注释) |
|
|
|
// 视频通话按钮(暂时隐藏) |
|
|
|
// Image.asset( |
|
|
|
// Assets.imagesSendVideoCall, |
|
|
|
// Assets.imagesVideoCall, |
|
|
|
// width: 24.w, |
|
|
|
// height: 24.w, |
|
|
|
// ).onTap(() { |
|
|
|
// widget.onVideoCall?.call(); |
|
|
|
// ).onTap(() async { |
|
|
|
// await widget.onVideoCall?.call(); |
|
|
|
// }), |
|
|
|
// 礼物按钮 |
|
|
|
// Image.asset(Assets.imagesGift, width: 24.w, height: 24.w).onTap(() { |
|
|
|
@ -344,3 +294,85 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// 表情特殊文本构建器 - 参考 TIMUIKitTextField 的实现 |
|
|
|
class MySpecialTextSpanBuilder extends SpecialTextSpanBuilder { |
|
|
|
@override |
|
|
|
SpecialText? createSpecialText( |
|
|
|
String flag, { |
|
|
|
TextStyle? textStyle, |
|
|
|
SpecialTextGestureTapCallback? onTap, |
|
|
|
required int index, |
|
|
|
}) { |
|
|
|
if (flag.isEmpty) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
// index 是 start flag 的结束位置,所以文本开始位置应该是 index - (flag.length - 1) |
|
|
|
// 使用 '[' 作为 startFlag,参考 EmojiText.flag = '[' |
|
|
|
if (isStart(flag, MyEmojiText.flag)) { |
|
|
|
return MyEmojiText( |
|
|
|
textStyle, |
|
|
|
start: index - (MyEmojiText.flag.length - 1), |
|
|
|
); |
|
|
|
} |
|
|
|
return null; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// 表情特殊文本类 - 参考 TIMUIKitTextField 的 EmojiText 实现 |
|
|
|
class MyEmojiText extends SpecialText { |
|
|
|
static const String flag = '['; |
|
|
|
final int start; |
|
|
|
|
|
|
|
MyEmojiText( |
|
|
|
TextStyle? textStyle, { |
|
|
|
required this.start, |
|
|
|
}) : super(MyEmojiText.flag, ']', textStyle); |
|
|
|
|
|
|
|
@override |
|
|
|
InlineSpan finishText() { |
|
|
|
// toString() 返回完整的匹配文本,例如 "[emoji:11]" |
|
|
|
final String key = toString(); |
|
|
|
|
|
|
|
// 检查是否是我们的表情格式 [emoji:数字] |
|
|
|
final RegExp emojiPattern = RegExp(r'^\[emoji:(\d+)\]$'); |
|
|
|
final match = emojiPattern.firstMatch(key); |
|
|
|
|
|
|
|
if (match != null && match.groupCount > 0) { |
|
|
|
final emojiId = match.group(1); |
|
|
|
if (emojiId != null && emojiId.isNotEmpty) { |
|
|
|
final emoji = EmojiConfig.getEmojiById(emojiId); |
|
|
|
if (emoji != null && emoji.path.isNotEmpty) { |
|
|
|
// 使用 ImageSpan,完全参考 TIMUIKitTextField 的实现 |
|
|
|
double size = 16; |
|
|
|
final TextStyle ts = textStyle!; |
|
|
|
if (ts.fontSize != null) { |
|
|
|
// 参考 TIMUIKitTextField: size = ts.fontSize! * 1.44 |
|
|
|
// 但我们使用 flutter_screenutil,所以需要适配 |
|
|
|
// 如果 fontSize 已经是适配后的值(如 14.sp),则直接计算 |
|
|
|
size = ts.fontSize! * 1.44; |
|
|
|
// 将 sp 单位转换为实际像素值,因为 ImageSpan 需要像素值 |
|
|
|
// flutter_screenutil 的 .sp 会返回像素值,所以这里直接使用 |
|
|
|
} else { |
|
|
|
// 如果没有设置 fontSize,使用默认值 14.sp * 1.44 |
|
|
|
size = 14.w * 1.44; |
|
|
|
} |
|
|
|
|
|
|
|
return ImageSpan( |
|
|
|
AssetImage(emoji.path), |
|
|
|
actualText: key, |
|
|
|
imageWidth: size, |
|
|
|
imageHeight: size, |
|
|
|
start: start, |
|
|
|
margin: const EdgeInsets.all(0), // 零边距避免空格问题 |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 如果匹配失败或表情不存在,显示原始文本 |
|
|
|
return TextSpan(text: key, style: textStyle); |
|
|
|
} |
|
|
|
} |