Browse Source

feat(call): 添加RTC通话消费定时器和表情输入框功能

- 在API URL中添加consumeOneOnOneRtcChannel接口地址
- 在CallController中实现消费定时器,每分钟调用一次消费接口
- 添加_startConsumeTimer、_stopConsumeTimer和_consumeOneOnOneRtcChannel方法
- 在通话接通和结束时启动和停止消费定时器
- 将聊天输入框替换为ExtendedTextField以支持表情显示
- 实现EmojiSpecialTextSpanBuilder和EmojiTextInputFormatter处理表情输入
- 在pubspec.yaml中添加extended_text_field依赖
- 在RTC API中添加consumeOneOnOneRtcChannel接口定义
master
Jolie 2 months ago
parent
commit
ed6dfdc5db
6 changed files with 243 additions and 98 deletions
  1. 69
      lib/controller/message/call_controller.dart
  2. 3
      lib/network/api_urls.dart
  3. 6
      lib/network/rtc_api.dart
  4. 34
      lib/network/rtc_api.g.dart
  5. 228
      lib/widget/message/chat_input_bar.dart
  6. 1
      pubspec.yaml

69
lib/controller/message/call_controller.dart

@ -97,6 +97,9 @@ class CallController extends GetxController {
// 30
Timer? _callTimeoutTimer;
// 1
Timer? _consumeTimer;
// UID
final Rxn<int> remoteUid = Rxn<int>();
@ -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<void> _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<bool> _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();
}

3
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';
}

6
lib/network/rtc_api.dart

@ -130,4 +130,10 @@ abstract class RtcApi {
Future<HttpResponse<BaseResponse<List<ChatAudioProductModel>>>> listChatAudioProduct(
@Query('toUserId') String toUserId,
);
/// RTC频道
@POST(ApiUrls.consumeOneOnOneRtcChannel)
Future<HttpResponse<BaseResponse<dynamic>>> consumeOneOnOneRtcChannel(
@Body() Map<String, dynamic> data,
);
}

34
lib/network/rtc_api.g.dart

@ -749,6 +749,40 @@ class _RtcApi implements RtcApi {
return httpResponse;
}
@override
Future<HttpResponse<BaseResponse<dynamic>>> consumeOneOnOneRtcChannel(
Map<String, dynamic> data,
) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
_data.addAll(data);
final _options = _setStreamType<HttpResponse<BaseResponse<dynamic>>>(
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<Map<String, dynamic>>(_options);
late BaseResponse<dynamic> _value;
try {
_value = BaseResponse<dynamic>.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<T>(RequestOptions requestOptions) {
if (T != dynamic &&
!(requestOptions.responseType == ResponseType.bytes ||

228
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<String> onSendMessage;
final ValueChanged<List<String>>? onImageSelected;
@ -129,71 +235,15 @@ class _ChatInputBarState extends State<ChatInputBar> {
//
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<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(
@ -215,46 +265,28 @@ class _ChatInputBarState extends State<ChatInputBar> {
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(() {}); //
},
),
),
),

1
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:

Loading…
Cancel
Save