diff --git a/lib/controller/message/call_controller.dart b/lib/controller/message/call_controller.dart index 12609f1..da44f39 100644 --- a/lib/controller/message/call_controller.dart +++ b/lib/controller/message/call_controller.dart @@ -97,6 +97,9 @@ class CallController extends GetxController { // 通话超时计时器(发起方等待对方接听的30秒超时) Timer? _callTimeoutTimer; + // 消费定时器(每隔1分钟调用一次消费接口) + Timer? _consumeTimer; + // 远端用户UID(用于显示远端视频) final Rxn remoteUid = Rxn(); @@ -440,6 +443,13 @@ class CallController extends GetxController { '📞 [CallController] 从消息中获取到发起方 UID: $initiatorUid,已设置 remoteUid', ); } + + // 接收方接听后,立即调用一次消费接口并启动定时器 + // 确保 _callChannelId 已设置 + if (_callChannelId != null && _callChannelId!.isNotEmpty) { + _startConsumeTimer(); + print('✅ [CallController] 接收方接听后已启动消费定时器'); + } } else { SmartDialog.showToast('获取RTC token失败'); return false; @@ -505,6 +515,7 @@ class CallController extends GetxController { // 停止计时和超时计时器 _stopCallTimer(); _stopCallTimeoutTimer(); + _stopConsumeTimer(); // 清理通话会话和远端用户UID currentCall.value = null; @@ -561,6 +572,43 @@ class CallController extends GetxController { _callTimeoutTimer = null; } + /// 启动消费定时器(每隔1分钟调用一次消费接口) + void _startConsumeTimer() { + _stopConsumeTimer(); // 先停止之前的定时器 + _consumeTimer = Timer.periodic(Duration(minutes: 1), (timer) { + _consumeOneOnOneRtcChannel(); + }); + print('✅ [CallController] 已启动消费定时器,每隔1分钟调用一次'); + } + + /// 停止消费定时器 + void _stopConsumeTimer() { + _consumeTimer?.cancel(); + _consumeTimer = null; + } + + /// 调用消费一对一RTC频道接口 + Future _consumeOneOnOneRtcChannel() async { + final consumeChannelId = _callChannelId; + if (consumeChannelId == null || consumeChannelId.isEmpty) { + print('⚠️ [CallController] channelId为空,无法调用消费接口'); + return; + } + + try { + final response = await _networkService.rtcApi.consumeOneOnOneRtcChannel({ + 'channelId': consumeChannelId, + }); + if (response.data.isSuccess) { + print('✅ [CallController] 已调用消费一对一RTC频道接口,channelId: $consumeChannelId'); + } else { + print('⚠️ [CallController] 消费一对一RTC频道接口失败: ${response.data.message}'); + } + } catch (e) { + print('⚠️ [CallController] 调用消费接口异常: $e'); + } + } + /// 发送通话消息 Future _sendCallMessage({ required String targetUserId, @@ -858,6 +906,9 @@ class CallController extends GetxController { // 停止通话计时器 _stopCallTimer(); + // 停止消费定时器 + _stopConsumeTimer(); + // 离开RTC频道 await RTCManager.instance.leaveChannel(); @@ -904,6 +955,23 @@ class CallController extends GetxController { // 通话接通 print('📞 [CallController] 通话已接通,callStatus=$callStatus'); + // 确保 _callChannelId 已设置(优先使用传入的 channelId) + if (channelId != null && channelId.isNotEmpty) { + _callChannelId = channelId; + } + + // 立即调用一次消费接口,然后启动定时器每隔1分钟调用一次 + // 如果定时器还没有启动,则启动它(避免重复启动) + if (_consumeTimer == null) { + _consumeOneOnOneRtcChannel(); + _startConsumeTimer(); + print('✅ [CallController] 通话接通后已启动消费定时器'); + } else { + // 如果定时器已经启动,只调用一次消费接口(可能是接收方接听后服务端更新了消息状态) + _consumeOneOnOneRtcChannel(); + print('✅ [CallController] 消费定时器已存在,只调用一次消费接口'); + } + // 如果是发起方,设置远端用户UID并启动计时器 if (callSession.isInitiator) { // 停止播放来电铃声(对方已接听) @@ -971,6 +1039,7 @@ class CallController extends GetxController { isSpeakerOn.value = false; _callChannelId = null; _callUid = null; + _stopConsumeTimer(); _callAudioPlayer.dispose(); super.onClose(); } diff --git a/lib/network/api_urls.dart b/lib/network/api_urls.dart index 66b78ef..b0f62c6 100644 --- a/lib/network/api_urls.dart +++ b/lib/network/api_urls.dart @@ -149,4 +149,7 @@ class ApiUrls { static const String listChatAudioProduct = 'dating-agency-chat-audio/user/list/chat-audio-product'; + static const String consumeOneOnOneRtcChannel = + 'dating-agency-chat-audio/user/consume/one-on-one/rtc-channel'; + } diff --git a/lib/network/rtc_api.dart b/lib/network/rtc_api.dart index 11db022..9346b0e 100644 --- a/lib/network/rtc_api.dart +++ b/lib/network/rtc_api.dart @@ -130,4 +130,10 @@ abstract class RtcApi { Future>>> listChatAudioProduct( @Query('toUserId') String toUserId, ); + + /// 消费一对一RTC频道 + @POST(ApiUrls.consumeOneOnOneRtcChannel) + Future>> consumeOneOnOneRtcChannel( + @Body() Map data, + ); } diff --git a/lib/network/rtc_api.g.dart b/lib/network/rtc_api.g.dart index d21199c..7dd2990 100644 --- a/lib/network/rtc_api.g.dart +++ b/lib/network/rtc_api.g.dart @@ -749,6 +749,40 @@ class _RtcApi implements RtcApi { return httpResponse; } + @override + Future>> consumeOneOnOneRtcChannel( + Map data, + ) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(data); + final _options = _setStreamType>>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'dating-agency-chat-audio/user/consume/one-on-one/rtc-channel', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late BaseResponse _value; + try { + _value = BaseResponse.fromJson( + _result.data!, + (json) => json as dynamic, + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/widget/message/chat_input_bar.dart b/lib/widget/message/chat_input_bar.dart index bfbd66a..945ddab 100644 --- a/lib/widget/message/chat_input_bar.dart +++ b/lib/widget/message/chat_input_bar.dart @@ -1,6 +1,8 @@ import 'package:dating_touchme_app/extension/ex_widget.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:extended_text_field/extended_text_field.dart'; import '../../generated/assets.dart'; import '../../config/emoji_config.dart'; @@ -8,6 +10,110 @@ import '../emoji_panel.dart'; import 'more_options_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 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 newCursorOffset = emojiStart; + + return TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newCursorOffset), + ); + } + } + } + + return newValue; + } +} + class ChatInputBar extends StatefulWidget { final ValueChanged onSendMessage; final ValueChanged>? onImageSelected; @@ -129,71 +235,15 @@ class _ChatInputBarState extends State { // 将表情添加到输入框 final currentText = _textController.text; final emojiText = '[emoji:${emoji.id}]'; - _textController.text = currentText + emojiText; + final newText = currentText + emojiText; + _textController.text = newText; // 将光标移到末尾 _textController.selection = TextSelection.fromPosition( - TextPosition(offset: _textController.text.length), + TextPosition(offset: newText.length), ); setState(() {}); // 刷新显示 } - /// 构建输入框内容(文本+表情) - List _buildInputContentWidgets() { - final List 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( @@ -215,46 +265,28 @@ class _ChatInputBarState extends State { borderRadius: BorderRadius.circular(5.h), ), padding: EdgeInsets.symmetric(horizontal: 16.w), - 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(() {}); // 刷新以更新表情显示 - }, - ), - // 表情显示层 - if (_textController.text.contains('[emoji:')) - Positioned.fill( - child: IgnorePointer( - child: Align( - alignment: Alignment.centerLeft, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _buildInputContentWidgets(), - ), - ), - ), - ), - ), + child: ExtendedTextField( + controller: _textController, + focusNode: _focusNode, + specialTextSpanBuilder: EmojiSpecialTextSpanBuilder(), + inputFormatters: [ + EmojiTextInputFormatter(), ], + decoration: InputDecoration( + border: InputBorder.none, + hintText: "请输入聊天内容~", + hintStyle: TextStyle( + fontSize: 14.sp, + color: Colors.grey, + ), + ), + style: TextStyle( + fontSize: 14.sp, + color: Colors.black, + ), + onChanged: (value) { + setState(() {}); // 刷新以更新表情显示 + }, ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index f7b5ece..fbc7ea5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,7 @@ dependencies: ota_update: ^7.1.0 flutter_local_notifications: ^19.5.0 app_badge_plus: ^1.2.6 + extended_text_field: ^16.0.2 dev_dependencies: flutter_test: