|
|
@ -1,8 +1,6 @@ |
|
|
import 'package:dating_touchme_app/extension/ex_widget.dart'; |
|
|
import 'package:dating_touchme_app/extension/ex_widget.dart'; |
|
|
import 'package:flutter/material.dart'; |
|
|
import 'package:flutter/material.dart'; |
|
|
import 'package:flutter/services.dart'; |
|
|
|
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
|
import 'package:extended_text_field/extended_text_field.dart'; |
|
|
|
|
|
|
|
|
|
|
|
import '../../generated/assets.dart'; |
|
|
import '../../generated/assets.dart'; |
|
|
import '../../config/emoji_config.dart'; |
|
|
import '../../config/emoji_config.dart'; |
|
|
@ -10,135 +8,6 @@ import '../emoji_panel.dart'; |
|
|
import 'more_options_view.dart'; |
|
|
import 'more_options_view.dart'; |
|
|
import 'voice_input_view.dart'; |
|
|
import 'voice_input_view.dart'; |
|
|
|
|
|
|
|
|
/// 表情特殊文本构建器 - 用于在 ExtendedTextField 中显示表情 |
|
|
|
|
|
class EmojiSpecialTextSpanBuilder extends SpecialTextSpanBuilder { |
|
|
|
|
|
@override |
|
|
|
|
|
SpecialText? createSpecialText( |
|
|
|
|
|
String flag, { |
|
|
|
|
|
TextStyle? textStyle, |
|
|
|
|
|
SpecialTextGestureTapCallback? onTap, |
|
|
|
|
|
required int index, |
|
|
|
|
|
}) { |
|
|
|
|
|
// 匹配 [emoji:xxx] 格式 |
|
|
|
|
|
if (flag.startsWith('[emoji:')) { |
|
|
|
|
|
return EmojiSpecialText( |
|
|
|
|
|
textStyle: textStyle, |
|
|
|
|
|
onTap: onTap, |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// 表情特殊文本类 |
|
|
|
|
|
class EmojiSpecialText extends SpecialText { |
|
|
|
|
|
EmojiSpecialText({ |
|
|
|
|
|
TextStyle? textStyle, |
|
|
|
|
|
SpecialTextGestureTapCallback? onTap, |
|
|
|
|
|
}) : super( |
|
|
|
|
|
'[emoji:', |
|
|
|
|
|
']', |
|
|
|
|
|
textStyle, |
|
|
|
|
|
onTap: onTap, |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
@override |
|
|
|
|
|
InlineSpan finishText() { |
|
|
|
|
|
// 提取表情ID |
|
|
|
|
|
final emojiId = toString().replaceAll('[emoji:', '').replaceAll(']', ''); |
|
|
|
|
|
final emoji = EmojiConfig.getEmojiById(emojiId); |
|
|
|
|
|
|
|
|
|
|
|
if (emoji != null) { |
|
|
|
|
|
// 返回包含表情图片的 WidgetSpan |
|
|
|
|
|
return WidgetSpan( |
|
|
|
|
|
child: Padding( |
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 2.w), |
|
|
|
|
|
child: Image.asset( |
|
|
|
|
|
emoji.path, |
|
|
|
|
|
width: 24.w, |
|
|
|
|
|
height: 24.w, |
|
|
|
|
|
fit: BoxFit.contain, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 如果表情不存在,返回普通文本 |
|
|
|
|
|
return TextSpan( |
|
|
|
|
|
text: toString(), |
|
|
|
|
|
style: textStyle, |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// 表情文本输入格式化器 - 处理删除时一次性删除整个表情标记 |
|
|
|
|
|
class EmojiTextInputFormatter extends TextInputFormatter { |
|
|
|
|
|
@override |
|
|
|
|
|
TextEditingValue formatEditUpdate( |
|
|
|
|
|
TextEditingValue oldValue, |
|
|
|
|
|
TextEditingValue newValue, |
|
|
|
|
|
) { |
|
|
|
|
|
// 如果文本长度减少(删除操作) |
|
|
|
|
|
if (newValue.text.length < oldValue.text.length) { |
|
|
|
|
|
final oldSelection = oldValue.selection; |
|
|
|
|
|
final cursorOffset = oldSelection.baseOffset; |
|
|
|
|
|
final deletedLength = oldValue.text.length - newValue.text.length; |
|
|
|
|
|
|
|
|
|
|
|
// 在整个文本中查找所有表情标记 |
|
|
|
|
|
final emojiRegex = RegExp(r'\[emoji:\d+\]'); |
|
|
|
|
|
final allMatches = emojiRegex.allMatches(oldValue.text); |
|
|
|
|
|
|
|
|
|
|
|
// 查找光标所在位置或光标前最近的表情标记 |
|
|
|
|
|
for (final match in allMatches) { |
|
|
|
|
|
final emojiStart = match.start; |
|
|
|
|
|
final emojiEnd = match.end; |
|
|
|
|
|
|
|
|
|
|
|
// 如果光标在表情标记内部(不包括开始位置,包括结束位置) |
|
|
|
|
|
if (cursorOffset > emojiStart && cursorOffset <= emojiEnd) { |
|
|
|
|
|
// 删除整个表情标记 |
|
|
|
|
|
final beforeEmoji = oldValue.text.substring(0, emojiStart); |
|
|
|
|
|
final afterEmoji = oldValue.text.substring(emojiEnd); |
|
|
|
|
|
final newText = beforeEmoji + afterEmoji; |
|
|
|
|
|
|
|
|
|
|
|
// 删除表情后,光标保持在删除操作发生的位置 |
|
|
|
|
|
// 跟删除普通文本一样:删除后光标停留在删除位置 |
|
|
|
|
|
// 光标位置 = 删除位置 - 表情长度 |
|
|
|
|
|
final emojiLength = emojiEnd - emojiStart; |
|
|
|
|
|
int newCursorOffset = (cursorOffset - emojiLength).clamp(0, newText.length); |
|
|
|
|
|
|
|
|
|
|
|
return TextEditingValue( |
|
|
|
|
|
text: newText, |
|
|
|
|
|
selection: TextSelection.collapsed(offset: newCursorOffset), |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 如果光标刚好在表情标记的开始位置,且删除的是表情标记内部的字符 |
|
|
|
|
|
if (cursorOffset == emojiStart) { |
|
|
|
|
|
// 检查删除的字符是否在表情标记范围内 |
|
|
|
|
|
if (cursorOffset + deletedLength <= emojiEnd) { |
|
|
|
|
|
// 删除整个表情标记 |
|
|
|
|
|
final beforeEmoji = oldValue.text.substring(0, emojiStart); |
|
|
|
|
|
final afterEmoji = oldValue.text.substring(emojiEnd); |
|
|
|
|
|
final newText = beforeEmoji + afterEmoji; |
|
|
|
|
|
|
|
|
|
|
|
// 删除表情后,光标保持在删除位置 |
|
|
|
|
|
// 光标位置 = 删除位置 - 表情长度 |
|
|
|
|
|
final emojiLength = emojiEnd - emojiStart; |
|
|
|
|
|
int newCursorOffset = (cursorOffset - emojiLength).clamp(0, newText.length); |
|
|
|
|
|
|
|
|
|
|
|
return TextEditingValue( |
|
|
|
|
|
text: newText, |
|
|
|
|
|
selection: TextSelection.collapsed(offset: newCursorOffset), |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return newValue; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
class ChatInputBar extends StatefulWidget { |
|
|
class ChatInputBar extends StatefulWidget { |
|
|
final ValueChanged<String> onSendMessage; |
|
|
final ValueChanged<String> onSendMessage; |
|
|
final ValueChanged<List<String>>? onImageSelected; |
|
|
final ValueChanged<List<String>>? onImageSelected; |
|
|
@ -260,15 +129,71 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
// 将表情添加到输入框 |
|
|
// 将表情添加到输入框 |
|
|
final currentText = _textController.text; |
|
|
final currentText = _textController.text; |
|
|
final emojiText = '[emoji:${emoji.id}]'; |
|
|
final emojiText = '[emoji:${emoji.id}]'; |
|
|
final newText = currentText + emojiText; |
|
|
|
|
|
_textController.text = newText; |
|
|
|
|
|
|
|
|
_textController.text = currentText + emojiText; |
|
|
// 将光标移到末尾 |
|
|
// 将光标移到末尾 |
|
|
_textController.selection = TextSelection.fromPosition( |
|
|
_textController.selection = TextSelection.fromPosition( |
|
|
TextPosition(offset: newText.length), |
|
|
|
|
|
|
|
|
TextPosition(offset: _textController.text.length), |
|
|
); |
|
|
); |
|
|
setState(() {}); // 刷新显示 |
|
|
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 |
|
|
@override |
|
|
Widget build(BuildContext context) { |
|
|
Widget build(BuildContext context) { |
|
|
return Column( |
|
|
return Column( |
|
|
@ -290,32 +215,46 @@ class _ChatInputBarState extends State<ChatInputBar> { |
|
|
borderRadius: BorderRadius.circular(5.h), |
|
|
borderRadius: BorderRadius.circular(5.h), |
|
|
), |
|
|
), |
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w), |
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w), |
|
|
alignment: Alignment.center, |
|
|
|
|
|
child: ExtendedTextField( |
|
|
|
|
|
controller: _textController, |
|
|
|
|
|
focusNode: _focusNode, |
|
|
|
|
|
specialTextSpanBuilder: EmojiSpecialTextSpanBuilder(), |
|
|
|
|
|
inputFormatters: [ |
|
|
|
|
|
EmojiTextInputFormatter(), |
|
|
|
|
|
], |
|
|
|
|
|
textAlignVertical: TextAlignVertical.center, |
|
|
|
|
|
decoration: InputDecoration( |
|
|
|
|
|
border: InputBorder.none, |
|
|
|
|
|
hintText: "请输入聊天内容~", |
|
|
|
|
|
hintStyle: TextStyle( |
|
|
|
|
|
fontSize: 14.sp, |
|
|
|
|
|
color: Colors.grey, |
|
|
|
|
|
|
|
|
child: Stack( |
|
|
|
|
|
children: [ |
|
|
|
|
|
// 真实的输入框 |
|
|
|
|
|
TextField( |
|
|
|
|
|
controller: _textController, |
|
|
|
|
|
focusNode: _focusNode, |
|
|
|
|
|
decoration: InputDecoration( |
|
|
|
|
|
border: InputBorder.none, |
|
|
|
|
|
hintText: "请输入聊天内容~", |
|
|
|
|
|
hintStyle: TextStyle( |
|
|
|
|
|
fontSize: 14.sp, |
|
|
|
|
|
color: Colors.grey, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 14.sp, |
|
|
|
|
|
color: _textController.text.contains('[emoji:') |
|
|
|
|
|
? Colors.transparent |
|
|
|
|
|
: Colors.black, |
|
|
|
|
|
), |
|
|
|
|
|
onChanged: (value) { |
|
|
|
|
|
setState(() {}); // 刷新以更新表情显示 |
|
|
|
|
|
}, |
|
|
), |
|
|
), |
|
|
isDense: true, |
|
|
|
|
|
contentPadding: EdgeInsets.symmetric(vertical: 0), |
|
|
|
|
|
), |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 14.sp, |
|
|
|
|
|
color: Colors.black, |
|
|
|
|
|
), |
|
|
|
|
|
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(), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
|