From 522809d5c5b0a052fd701d4a575270d777faa41c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=AD=90=E8=B4=A4?= Date: Tue, 6 Jan 2026 13:59:38 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=94=B9=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/model/mine/rose_data.dart | 6 +++++- lib/pages/mine/rose_page.dart | 2 +- lib/widget/live/live_recharge_popup.dart | 2 +- lib/widget/live/live_room_pay_item.dart | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/model/mine/rose_data.dart b/lib/model/mine/rose_data.dart index a7fee3a..59c3a78 100644 --- a/lib/model/mine/rose_data.dart +++ b/lib/model/mine/rose_data.dart @@ -11,6 +11,7 @@ class RoseData { String? purchaseTimeValue; String? validityPeriodDays; String? liveDurationHours; + String? unitSellingPriceStr; RoseData( {this.productId, @@ -24,7 +25,8 @@ class RoseData { this.unitSellingPrice, this.purchaseTimeValue, this.validityPeriodDays, - this.liveDurationHours + this.liveDurationHours, + this.unitSellingPriceStr, }); RoseData.fromJson(Map json) { @@ -40,6 +42,7 @@ class RoseData { purchaseTimeValue = json['purchaseTimeValue']; validityPeriodDays = json['validityPeriodDays']; liveDurationHours = json['liveDurationHours']; + unitSellingPriceStr = json['unitSellingPriceStr']; } Map toJson() { @@ -56,6 +59,7 @@ class RoseData { data['purchaseTimeValue'] = this.purchaseTimeValue; data['validityPeriodDays'] = this.validityPeriodDays; data['liveDurationHours'] = this.liveDurationHours; + data['unitSellingPriceStr'] = this.unitSellingPriceStr; return data; } } diff --git a/lib/pages/mine/rose_page.dart b/lib/pages/mine/rose_page.dart index de0713f..0ac7ea7 100644 --- a/lib/pages/mine/rose_page.dart +++ b/lib/pages/mine/rose_page.dart @@ -289,7 +289,7 @@ class _PayItemState extends State { ), ), Text( - "${widget.item.unitSellingPrice!.toStringAsFixed(2)}元", + "${widget.item.unitSellingPriceStr}元", style: TextStyle( fontSize: 11.w, color: const Color.fromRGBO(144, 144, 144, 144), diff --git a/lib/widget/live/live_recharge_popup.dart b/lib/widget/live/live_recharge_popup.dart index 71f4c2e..582cdc1 100644 --- a/lib/widget/live/live_recharge_popup.dart +++ b/lib/widget/live/live_recharge_popup.dart @@ -109,7 +109,7 @@ class LiveRechargePopup extends StatelessWidget { final payList = roseList.map((rose) { return { 'num': rose.purchaseTimeValue ?? 0, - 'price': rose.unitSellingPrice ?? 0, + 'price': rose.unitSellingPriceStr ?? "0", 'hasTag': rose.detailDesc != null && rose.detailDesc!.isNotEmpty, 'tagText': rose.detailDesc ?? '', }; diff --git a/lib/widget/live/live_room_pay_item.dart b/lib/widget/live/live_room_pay_item.dart index 02eca9f..b8ca6a5 100644 --- a/lib/widget/live/live_room_pay_item.dart +++ b/lib/widget/live/live_room_pay_item.dart @@ -70,7 +70,7 @@ class _LiveRoomPayItemState extends State { ), ), Text( - "${(widget.item["price"])?.toStringAsFixed(2) ?? '0.00'}元", + "${(widget.item["price"])}元", style: TextStyle( fontSize: 11.w, color: const Color.fromRGBO(144, 144, 144, 1), From 99cb8bf6be0c4edb80f7cafa30e7725954390854 Mon Sep 17 00:00:00 2001 From: ZHR007 Date: Tue, 6 Jan 2026 14:04:47 +0800 Subject: [PATCH 2/6] no message --- lib/controller/discover/room_controller.dart | 2 -- lib/pages/discover/live_room_page.dart | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/controller/discover/room_controller.dart b/lib/controller/discover/room_controller.dart index 84cb51d..f10499a 100644 --- a/lib/controller/discover/room_controller.dart +++ b/lib/controller/discover/room_controller.dart @@ -671,10 +671,8 @@ class RoomController extends GetxController with WidgetsBindingObserver { print('关闭小窗口失败: $e'); } } - // 被踢用户:离开房间 await leaveChannel(); - // 跳转到结束直播页面,并传入被踢出标识 Get.off( () => LiveEndPage(isKickedOut: true, operatorName: operatorName), diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index d2eef1b..5716bb8 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -107,6 +107,7 @@ class _LiveRoomPageState extends State { _messageController.dispose(); _inputDialogController.dispose(); _inputDialogFocusNode.dispose(); + SmartDialog.dismiss(); // 退出房间时清空RTM消息 if (Get.isRegistered()) { final roomController = Get.find(); From ed6dfdc5db3f6932b4bff7145cc47a30325a0f58 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Tue, 6 Jan 2026 15:15:01 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(call):=20=E6=B7=BB=E5=8A=A0RTC?= =?UTF-8?q?=E9=80=9A=E8=AF=9D=E6=B6=88=E8=B4=B9=E5=AE=9A=E6=97=B6=E5=99=A8?= =?UTF-8?q?=E5=92=8C=E8=A1=A8=E6=83=85=E8=BE=93=E5=85=A5=E6=A1=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在API URL中添加consumeOneOnOneRtcChannel接口地址 - 在CallController中实现消费定时器,每分钟调用一次消费接口 - 添加_startConsumeTimer、_stopConsumeTimer和_consumeOneOnOneRtcChannel方法 - 在通话接通和结束时启动和停止消费定时器 - 将聊天输入框替换为ExtendedTextField以支持表情显示 - 实现EmojiSpecialTextSpanBuilder和EmojiTextInputFormatter处理表情输入 - 在pubspec.yaml中添加extended_text_field依赖 - 在RTC API中添加consumeOneOnOneRtcChannel接口定义 --- lib/controller/message/call_controller.dart | 69 ++++++ lib/network/api_urls.dart | 3 + lib/network/rtc_api.dart | 6 + lib/network/rtc_api.g.dart | 34 +++ lib/widget/message/chat_input_bar.dart | 228 +++++++++++--------- pubspec.yaml | 1 + 6 files changed, 243 insertions(+), 98 deletions(-) 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: From c604fcc72d7eb9d3a5aa233bac0a8277ab55909e Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Tue, 6 Jan 2026 15:40:08 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=E8=A1=A8?= =?UTF-8?q?=E6=83=85=E5=88=A0=E9=99=A4=E6=97=B6=E5=85=89=E6=A0=87=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E9=97=AE=E9=A2=98=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=B9=B4?= =?UTF-8?q?=E9=BE=84=E8=AE=A1=E7=AE=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复了删除表情标记时光标位置计算错误的问题 - 优化了表情标记删除逻辑,正确处理光标在表情开始位置的情况 - 添加了根据出生年份计算年龄的工具方法 - 更新了聊天页面中年龄显示逻辑,使用动态计算的年龄值 --- lib/pages/message/chat_page.dart | 21 +++++++++++++++- lib/widget/message/chat_input_bar.dart | 33 ++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/lib/pages/message/chat_page.dart b/lib/pages/message/chat_page.dart index cfca2aa..fd0906f 100644 --- a/lib/pages/message/chat_page.dart +++ b/lib/pages/message/chat_page.dart @@ -35,6 +35,25 @@ class ChatPage extends StatefulWidget { } class _ChatPageState extends State { + /// 根据出生年份计算年龄 + String _calculateAgeFromBirthYear(String birthYear) { + if (birthYear.isEmpty) { + return '0'; + } + + try { + final year = int.tryParse(birthYear); + if (year == null) { + return '0'; + } + + final currentYear = DateTime.now().year; + final age = currentYear - year; + return age > 0 ? age.toString() : '0'; + } catch (e) { + return '0'; + } + } final ScrollController _scrollController = ScrollController(); bool _isLoadingMore = false; late ChatController _controller; @@ -515,7 +534,7 @@ class _ChatPageState extends State { ), SizedBox(width: 4.w), Text( - '${marriageData.age}', + _calculateAgeFromBirthYear(marriageData.birthYear), style: TextStyle( fontSize: 12.sp, color: Colors.grey[700], diff --git a/lib/widget/message/chat_input_bar.dart b/lib/widget/message/chat_input_bar.dart index 945ddab..d26ce19 100644 --- a/lib/widget/message/chat_input_bar.dart +++ b/lib/widget/message/chat_input_bar.dart @@ -82,6 +82,7 @@ class EmojiTextInputFormatter extends TextInputFormatter { 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+\]'); @@ -92,21 +93,45 @@ class EmojiTextInputFormatter extends TextInputFormatter { final emojiStart = match.start; final emojiEnd = match.end; - // 如果光标在表情标记内部(包括开始和结束位置) - if (cursorOffset >= emojiStart && cursorOffset <= emojiEnd) { + // 如果光标在表情标记内部(不包括开始位置,包括结束位置) + 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; + // 删除表情后,光标保持在删除操作发生的位置 + // 跟删除普通文本一样:删除后光标停留在删除位置 + // 光标位置 = 删除位置 - 表情长度 + 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), + ); + } + } } } From 3f43ecb8c102fbd14812f0e79095a1df136096ab Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Tue, 6 Jan 2026 17:11:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(call):=20=E4=BF=AE=E5=A4=8D=E9=80=9A?= =?UTF-8?q?=E8=AF=9D=E5=8A=9F=E8=83=BD=E4=B8=AD=E7=9A=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=92=8C=E4=BB=B7=E6=A0=BC=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在送礼失败时重置 isCreatingChannel 状态避免状态不一致 - 修复创建RTC频道失败时的返回值处理,添加 channelData 为 null 的检查 - 修复通话频道创建成功时的空指针访问问题 - 延迟1秒调用 _consumeOneOnOneRtcChannel 方法确保会话正确建立 - 修复免费产品价格显示为空字符串的格式化逻辑 - 优化通话类型选择对话框的价格显示,免费产品不显示价格信息 --- lib/controller/message/call_controller.dart | 16 +++++++++++----- .../message/call_type_selection_dialog.dart | 11 +++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/controller/message/call_controller.dart b/lib/controller/message/call_controller.dart index da44f39..291bbea 100644 --- a/lib/controller/message/call_controller.dart +++ b/lib/controller/message/call_controller.dart @@ -168,6 +168,7 @@ class CallController extends GetxController { if (!response.data.data!.success && response.data.data!.code == 'E0002') { // 玫瑰不足,显示 toast 并弹出充值弹框 SmartDialog.showToast('玫瑰不足请充值'); + isCreatingChannel.value = false; Get.log('❌ 送礼失败: ${response.data.data}'); // 使用 addPostFrameCallback 确保在下一帧显示弹框,避免与 toast 冲突 WidgetsBinding.instance.addPostFrameCallback((_) { @@ -259,15 +260,17 @@ class CallController extends GetxController { type: type, toUserId: targetUserId, ); - _callUid = channelData?.uid; - _callChannelId = channelData?.channelId; - if (channelData == null) { + if (channelData == null) { + return false; + } + if (!channelData.success) { print('❌ [CallController] 创建RTC频道失败,无法发起通话'); SmartDialog.showToast('创建通话频道失败'); return false; } - - print('✅ [CallController] RTC频道创建成功: ${channelData.channelId}'); + _callUid = channelData?.uid; + _callChannelId = channelData?.channelId; + print('✅ [CallController] RTC频道创建成功: ${channelData!.channelId}'); // 创建通话会话 final session = CallSession( @@ -447,6 +450,9 @@ class CallController extends GetxController { // 接收方接听后,立即调用一次消费接口并启动定时器 // 确保 _callChannelId 已设置 if (_callChannelId != null && _callChannelId!.isNotEmpty) { + Future .delayed(Duration(seconds: 1), () async { + await _consumeOneOnOneRtcChannel(); + }); _startConsumeTimer(); print('✅ [CallController] 接收方接听后已启动消费定时器'); } diff --git a/lib/widget/message/call_type_selection_dialog.dart b/lib/widget/message/call_type_selection_dialog.dart index 21658d4..96072fe 100644 --- a/lib/widget/message/call_type_selection_dialog.dart +++ b/lib/widget/message/call_type_selection_dialog.dart @@ -38,11 +38,18 @@ class CallTypeSelectionDialog extends StatelessWidget { /// 格式化价格显示 String _formatPrice(ChatAudioProductModel? product) { if (product == null) return '35玫瑰/分钟'; + + // 如果是免费产品,不显示分钟 + if (product.isFreeProduct) { + return ''; + } + return '${product.unitSellingPrice.toInt()}玫瑰/分钟'; } @override Widget build(BuildContext context) { + String price = _formatPrice(voiceProduct); return Container( decoration: BoxDecoration( color: Colors.white, @@ -65,7 +72,7 @@ class CallTypeSelectionDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.w), child: Center( child: Text( - '语音通话 (${_formatPrice(voiceProduct)})', + price.isNotEmpty ? '语音通话 ($price)' : '语音通话', style: TextStyle( fontSize: 16.sp, color: const Color.fromRGBO(51, 51, 51, 1), @@ -91,7 +98,7 @@ class CallTypeSelectionDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.w), child: Center( child: Text( - '视频通话 (${_formatPrice(videoProduct)})', + price.isNotEmpty ? '视频通话 ($price)' : '视频通话', style: TextStyle( fontSize: 16.sp, color: const Color.fromRGBO(51, 51, 51, 1), From a1b3d8df33a550783848a58a77a1d2a6d518585e Mon Sep 17 00:00:00 2001 From: ZHR007 Date: Tue, 6 Jan 2026 18:18:57 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E7=9B=B4=E6=92=AD=E9=97=B4=E7=9A=84popup?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=97=B6=EF=BC=8C=E9=A1=B5=E9=9D=A2=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E9=94=AE=E7=9A=84=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/controller/discover/room_controller.dart | 9 + lib/pages/discover/live_room_page.dart | 284 ++++++++++-------- .../live/live_room_anchor_showcase.dart | 9 +- .../live/live_room_notice_chat_panel.dart | 4 + lib/widget/live/live_room_user_header.dart | 4 + 5 files changed, 178 insertions(+), 132 deletions(-) diff --git a/lib/controller/discover/room_controller.dart b/lib/controller/discover/room_controller.dart index f10499a..ce0cb06 100644 --- a/lib/controller/discover/room_controller.dart +++ b/lib/controller/discover/room_controller.dart @@ -59,6 +59,7 @@ class RoomController extends GetxController with WidgetsBindingObserver { /// 玫瑰数量 final RxInt roseCount = 0.obs; + var isDialogShowing = false.obs; /// 消息服务实例 final LiveChatMessageService _messageService = @@ -94,6 +95,10 @@ class RoomController extends GetxController with WidgetsBindingObserver { chatMessages.clear(); } + void setDialogDismiss(bool flag){ + isDialogShowing.value = flag; + } + /// 注册消息监听 void _registerMessageListener() { _messageService.registerMessageListener( @@ -460,8 +465,12 @@ class RoomController extends GetxController with WidgetsBindingObserver { Get.log('❌ 送礼失败: ${response.data.data}'); // 使用 addPostFrameCallback 确保在下一帧显示弹框,避免与 toast 冲突 WidgetsBinding.instance.addPostFrameCallback((_) { + setDialogDismiss(true); SmartDialog.show( alignment: Alignment.bottomCenter, + onDismiss: (){ + setDialogDismiss(false); + }, maskColor: Colors.black.withOpacity(0.5), builder: (_) => const LiveRechargePopup(), ); diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index 5716bb8..e8e7bc9 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -184,9 +184,14 @@ class _LiveRoomPageState extends State { FocusScope.of(context).unfocus(); // 刷新玫瑰数量 await _roomController.getVirtualAccount(); + _roomController.setDialogDismiss(true); SmartDialog.show( + backType: SmartBackType.block, alignment: Alignment.bottomCenter, maskColor: TDTheme.of(context).fontGyColor2, + onDismiss: () { + _roomController.setDialogDismiss(false); + }, builder: (_) => Obx(() { // 优先使用 API 返回的 giftProducts,如果为空则使用后备列表 final giftProducts = _roomController.giftProducts; @@ -205,9 +210,13 @@ class _LiveRoomPageState extends State { void _showRechargePopup() { // 隐藏键盘 FocusScope.of(context).unfocus(); + _roomController.setDialogDismiss(true); SmartDialog.show( alignment: Alignment.bottomCenter, maskColor: TDTheme.of(context).fontGyColor2, + onDismiss: (){ + _roomController.setDialogDismiss(false); + }, builder: (_) => const LiveRechargePopup(), ); } @@ -234,151 +243,164 @@ class _LiveRoomPageState extends State { }); } }); + return Obx(() { + return PopScope( + canPop: !_roomController.isDialogShowing.value, + onPopInvokedWithResult: (bool didPop, Object? result) async { + // SmartDialog.dismiss(); + // print('256>22>>' + didPop.toString()); + // if (didPop) return; - return PopScope( - onPopInvokedWithResult: (bool didPop, Object? result) async { - SmartDialog.dismiss(); - // 退出房间时清空RTM消息 - if (Get.isRegistered()) { - final roomController = Get.find(); - roomController.chatMessages.clear(); - } - // 如果还没有执行 pop,手动调用 Get.back() - if (!didPop) { - Get.back(); - } - // 等待页面关闭后再显示小窗口,确保小窗口能正确显示 - Future.delayed(const Duration(milliseconds: 200), () { - _overlayController.show(); - }); - }, - child: Scaffold( - resizeToAvoidBottomInset: false, - body: Stack( - children: [ - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color.fromRGBO(248, 242, 255, 1), - Color.fromRGBO(247, 247, 247, 1), - ], + // 如果有对话框显示,关闭对话框 + if (_roomController.isDialogShowing.value) { + SmartDialog.dismiss(); + return; // 阻止页面返回 + } + // 退出房间时清空RTM消息 + if (Get.isRegistered()) { + final roomController = Get.find(); + roomController.chatMessages.clear(); + } + // 如果还没有执行 pop,手动调用 Get.back() + if (!didPop) { + Get.back(); + } + // 等待页面关闭后再显示小窗口,确保小窗口能正确显示 + Future.delayed(const Duration(milliseconds: 200), () { + _overlayController.show(); + }); + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Stack( + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color.fromRGBO(248, 242, 255, 1), + Color.fromRGBO(247, 247, 247, 1), + ], + ), ), ), - ), - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Color.fromRGBO(19, 16, 47, 1), - Color.fromRGBO(19, 16, 47, 1), - ], + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Color.fromRGBO(19, 16, 47, 1), + Color.fromRGBO(19, 16, 47, 1), + ], + ), ), ), - ), - Container( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - child: Column( - children: [ - SizedBox(height: 10.w), - Obx(() { - final detail = _roomController.rtcChannelDetail.value; - final anchorInfo = detail?.anchorInfo; + Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + ), + child: Column( + children: [ + SizedBox(height: 10.w), + Obx(() { + final detail = _roomController.rtcChannelDetail.value; + final anchorInfo = detail?.anchorInfo; - final userName = anchorInfo?.nickName ?? '用户'; - final avatarAsset = anchorInfo?.profilePhoto ?? Assets.imagesUserAvatar; - const popularityText = '0'; // TODO: 使用真实数据 + final userName = anchorInfo?.nickName ?? '用户'; + final avatarAsset = + anchorInfo?.profilePhoto ?? Assets.imagesUserAvatar; + const popularityText = '0'; // TODO: 使用真实数据 - return LiveRoomUserHeader( - userName: userName, - popularityText: popularityText, - avatarAsset: avatarAsset, - onCloseTap: () { - SmartDialog.dismiss(); - // 退出房间时清空RTM消息 - if (Get.isRegistered()) { - final roomController = Get.find(); - roomController.chatMessages.clear(); - } - Get.back(); - // 等待页面关闭后再显示小窗口,确保小窗口能正确显示 - Future.delayed(const Duration(milliseconds: 200), () { - _overlayController.show(); - }); - }, - ); - }), - SizedBox(height: 7.w), - LiveRoomAnchorShowcase(), - SizedBox(height: 5.w), - const LiveRoomActiveSpeaker(), - SizedBox(height: 9.w), - Expanded(child: const LiveRoomNoticeChatPanel()), - // 根据键盘状态显示/隐藏 LiveRoomActionBar - if (MediaQuery.of(context).viewInsets.bottom == 0) - SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - bottom: 10.w, - left: 0, - right: 0, - ), - child: LiveRoomActionBar( - messageController: _messageController, - onMessageChanged: (value) { - message = value; - }, - onSendTap: _sendMessage, - onGiftTap: _showGiftPopup, - onChargeTap: _showRechargePopup, - onInputTap: _openInputDialog, + return LiveRoomUserHeader( + userName: userName, + popularityText: popularityText, + avatarAsset: avatarAsset, + onCloseTap: () { + SmartDialog.dismiss(); + // 退出房间时清空RTM消息 + if (Get.isRegistered()) { + final roomController = Get.find(); + roomController.chatMessages.clear(); + } + Get.back(); + // 等待页面关闭后再显示小窗口,确保小窗口能正确显示 + Future.delayed(const Duration(milliseconds: 200), () { + _overlayController.show(); + }); + }, + ); + }), + SizedBox(height: 7.w), + LiveRoomAnchorShowcase(), + SizedBox(height: 5.w), + const LiveRoomActiveSpeaker(), + SizedBox(height: 9.w), + Expanded(child: const LiveRoomNoticeChatPanel()), + // 根据键盘状态显示/隐藏 LiveRoomActionBar + if (MediaQuery.of(context).viewInsets.bottom == 0) + SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + bottom: 10.w, + left: 0, + right: 0, + ), + child: LiveRoomActionBar( + messageController: _messageController, + onMessageChanged: (value) { + message = value; + }, + onSendTap: _sendMessage, + onGiftTap: _showGiftPopup, + onChargeTap: _showRechargePopup, + onInputTap: _openInputDialog, + ), ), ), - ), - ], - ), - ), - // SVGA 动画播放组件 - const SvgaPlayerWidget(), - // 输入对话框 - if (_showInputDialog) ...[ - // 遮罩层,点击时隐藏对话框和键盘(放在对话框下方,对话框会在上面拦截点击) - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - // 隐藏键盘 - FocusScope.of(context).unfocus(); - // 隐藏对话框 - _hideInputDialog(); - }, - child: Container(color: Colors.transparent), + ], ), ), - // 输入对话框(放在遮罩层上面,会自动拦截点击事件) - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - left: 0, - right: 0, - bottom: MediaQuery.of(context).viewInsets.bottom, - child: _InputDialogWidget( - controller: _inputDialogController, - focusNode: _inputDialogFocusNode, - onSend: _sendInputDialogMessage, - onClose: _hideInputDialog, + // SVGA 动画播放组件 + const SvgaPlayerWidget(), + // 输入对话框 + if (_showInputDialog) ...[ + // 遮罩层,点击时隐藏对话框和键盘(放在对话框下方,对话框会在上面拦截点击) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + // 隐藏键盘 + FocusScope.of(context).unfocus(); + // 隐藏对话框 + _hideInputDialog(); + }, + child: Container(color: Colors.transparent), + ), ), - ), + // 输入对话框(放在遮罩层上面,会自动拦截点击事件) + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + left: 0, + right: 0, + bottom: MediaQuery.of(context).viewInsets.bottom, + child: _InputDialogWidget( + controller: _inputDialogController, + focusNode: _inputDialogFocusNode, + onSend: _sendInputDialogMessage, + onClose: _hideInputDialog, + ), + ), + ], ], - ], + ), ), - ), - ); + ); + }); } } diff --git a/lib/widget/live/live_room_anchor_showcase.dart b/lib/widget/live/live_room_anchor_showcase.dart index 8a768fe..8dd765c 100644 --- a/lib/widget/live/live_room_anchor_showcase.dart +++ b/lib/widget/live/live_room_anchor_showcase.dart @@ -376,9 +376,13 @@ class _LiveRoomAnchorShowcaseState extends State { } void _showGuestListDialog(BuildContext context, bool isMaleSeat) { + _roomController.setDialogDismiss(true); SmartDialog.show( alignment: Alignment.bottomCenter, maskColor: Colors.black.withOpacity(0.5), + onDismiss: () { + _roomController.setDialogDismiss(false); + }, builder: (context) { return LiveRoomGuestListDialog( initialTab: isMaleSeat ? 1 : 0, // 0: 女嘉宾, 1: 男嘉宾 @@ -406,10 +410,13 @@ class _LiveRoomAnchorShowcaseState extends State { // 创建必要的 ValueNotifier final activeGift = ValueNotifier(null); final giftNum = ValueNotifier(1); - + _roomController.setDialogDismiss(true); SmartDialog.show( alignment: Alignment.bottomCenter, maskColor: Colors.black.withOpacity(0.5), + onDismiss: (){ + _roomController.setDialogDismiss(false); + }, builder: (context) { return Obx(() { // 获取礼物列表 diff --git a/lib/widget/live/live_room_notice_chat_panel.dart b/lib/widget/live/live_room_notice_chat_panel.dart index 8cc4d72..f5d7584 100644 --- a/lib/widget/live/live_room_notice_chat_panel.dart +++ b/lib/widget/live/live_room_notice_chat_panel.dart @@ -180,9 +180,13 @@ class _LiveRoomNoticeChatPanelState extends State { final cardNum = linkMicCard?.num ?? 0; // 如果显示"上麦20玫瑰"且玫瑰数量小于20,弹出充值弹框 if (cardNum == 0 && controller.roseCount.value < 20) { + controller.setDialogDismiss(true); SmartDialog.show( alignment: Alignment.bottomCenter, maskColor: Colors.black.withOpacity(0.5), + onDismiss: (){ + controller.setDialogDismiss(false); + }, builder: (_) => const LiveRechargePopup(), ); return; diff --git a/lib/widget/live/live_room_user_header.dart b/lib/widget/live/live_room_user_header.dart index bc325db..11dbc72 100644 --- a/lib/widget/live/live_room_user_header.dart +++ b/lib/widget/live/live_room_user_header.dart @@ -84,8 +84,12 @@ class LiveRoomUserHeader extends StatelessWidget { if (isHost) GestureDetector( onTap: () { + roomController.setDialogDismiss(true); SmartDialog.showAttach( targetContext: context, + onDismiss: (){ + roomController.setDialogDismiss(false); + }, builder: (context) { // 判断是否有嘉宾在连麦 final hasGuests =