diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index 280deab..babe4dc 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -343,6 +343,7 @@ "roomRedPacketHistoryTitle": "سجل إرسال حزم الحظ", "roomRedPacketHistoryItemTitle": "حزمة حظ ({1} مظروفاً)", "roomRedPacketHistoryStatusSent": "تم الإرسال", + "roomRedPacketHistoryStatusFinished": "تم استلامها بالكامل", "roomRedPacketHistoryStatusPending": "قيد الانتظار", "roomRedPacketHistoryStatusReturned": "تم الإرجاع", "roomRedPacketHistoryRefund": "المسترد: {1}", diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index 787bf0f..efe58da 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -191,6 +191,7 @@ "roomRedPacketHistoryTitle": "লাকি প্যাক পাঠানোর ইতিহাস", "roomRedPacketHistoryItemTitle": "লাকি প্যাক ({1} খাম)", "roomRedPacketHistoryStatusSent": "পাঠানো হয়েছে", + "roomRedPacketHistoryStatusFinished": "সব গ্রহণ করা হয়েছে", "roomRedPacketHistoryStatusPending": "অপেক্ষায়", "roomRedPacketHistoryStatusReturned": "ফেরত দেওয়া হয়েছে", "roomRedPacketHistoryRefund": "ফেরত: {1}", diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index 3373725..896367b 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -162,6 +162,7 @@ "roomRedPacketHistoryTitle": "Lucky Pack Delivery History", "roomRedPacketHistoryItemTitle": "Lucky Bag ({1} red envelopes)", "roomRedPacketHistoryStatusSent": "Sent", + "roomRedPacketHistoryStatusFinished": "Fully claimed", "roomRedPacketHistoryStatusPending": "Pending", "roomRedPacketHistoryStatusReturned": "Returned", "roomRedPacketHistoryRefund": "Refund: {1}", diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index 1b307e8..f974a8e 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -151,6 +151,7 @@ "roomRedPacketHistoryTitle": "Şans Paketi Gönderim Geçmişi", "roomRedPacketHistoryItemTitle": "Şans Paketi ({1} zarf)", "roomRedPacketHistoryStatusSent": "Gönderildi", + "roomRedPacketHistoryStatusFinished": "Tamamı alındı", "roomRedPacketHistoryStatusPending": "Beklemede", "roomRedPacketHistoryStatusReturned": "İade edildi", "roomRedPacketHistoryRefund": "İade: {1}", diff --git a/lib/app/config/configs/sc_variant1_config.dart b/lib/app/config/configs/sc_variant1_config.dart index 64e7c6f..cac760a 100644 --- a/lib/app/config/configs/sc_variant1_config.dart +++ b/lib/app/config/configs/sc_variant1_config.dart @@ -16,7 +16,7 @@ class SCVariant1Config implements AppConfig { @override String get apiHost => const String.fromEnvironment( 'API_HOST', - defaultValue: 'https://jvapi.haiyihy.com/', + defaultValue: 'http://192.168.110.64:1100/', ); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 本地“http://192.168.110.64:1100/”,线上“https://jvapi.haiyihy.com/” @override diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart index 1637e24..8ca3d26 100644 --- a/lib/app_localizations.dart +++ b/lib/app_localizations.dart @@ -655,6 +655,9 @@ class SCAppLocalizations { String get roomRedPacketHistoryStatusSent => translate('roomRedPacketHistoryStatusSent'); + String get roomRedPacketHistoryStatusFinished => + translate('roomRedPacketHistoryStatusFinished'); + String get roomRedPacketHistoryStatusPending => translate('roomRedPacketHistoryStatusPending'); diff --git a/lib/modules/room/voice_room_route.dart b/lib/modules/room/voice_room_route.dart index 2db64dc..d696545 100644 --- a/lib/modules/room/voice_room_route.dart +++ b/lib/modules/room/voice_room_route.dart @@ -130,6 +130,29 @@ class VoiceRoomRoute implements SCIRouterProvider { }); } + static void popVoiceRoomToPrevious( + BuildContext context, { + bool rootNavigator = false, + }) { + popVoiceRoomToPreviousOn( + Navigator.of(context, rootNavigator: rootNavigator), + ); + } + + static void popVoiceRoomToPreviousOn(NavigatorState navigator) { + if (!isVoiceRoomOpen) { + return; + } + var removedVoiceRoom = false; + navigator.popUntil((route) { + if (route.settings.name == voiceRoom) { + removedVoiceRoom = true; + return false; + } + return removedVoiceRoom || route.isFirst; + }); + } + static Future openRoomEdit( BuildContext context, { required bool needRestCurrentRoomInfo, diff --git a/lib/modules/user/task/task_page.dart b/lib/modules/user/task/task_page.dart index 3cd0569..bffb9ef 100644 --- a/lib/modules/user/task/task_page.dart +++ b/lib/modules/user/task/task_page.dart @@ -24,6 +24,7 @@ class _TaskPageState extends State static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png'; static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png'; static const String _taskDefaultAsset = 'sc_images/index/sc_icon_task.png'; + static const Duration _taskStatusCacheRefreshDelay = Duration(seconds: 65); final SCAccountRepository _repository = SCAccountRepository(); final Set _claimedTaskIds = {}; @@ -33,6 +34,7 @@ class _TaskPageState extends State bool _loadingTasks = false; String? _toastMessage; Timer? _toastTimer; + Timer? _cacheRefreshTimer; PageRoute? _routeObserverRoute; @override @@ -47,6 +49,7 @@ class _TaskPageState extends State WidgetsBinding.instance.removeObserver(this); routeObserver.unsubscribe(this); _toastTimer?.cancel(); + _cacheRefreshTimer?.cancel(); super.dispose(); } @@ -68,6 +71,7 @@ class _TaskPageState extends State if (state == AppLifecycleState.resumed) { debugPrint('[TaskCenter][Page] refresh on app resumed'); unawaited(_loadTasks()); + _scheduleTaskStatusCacheRefresh('app resumed'); } } @@ -75,6 +79,7 @@ class _TaskPageState extends State void didPopNext() { debugPrint('[TaskCenter][Page] refresh on route return'); unawaited(_loadTasks()); + _scheduleTaskStatusCacheRefresh('route return'); } Future _loadTasks() async { @@ -113,6 +118,27 @@ class _TaskPageState extends State } } + Future _handleManualRefresh() async { + await _loadTasks(); + _scheduleTaskStatusCacheRefresh('manual refresh'); + } + + void _scheduleTaskStatusCacheRefresh(String reason) { + _cacheRefreshTimer?.cancel(); + debugPrint( + '[TaskCenter][Page] schedule cache refresh reason=$reason ' + 'delayMs=${_taskStatusCacheRefreshDelay.inMilliseconds}', + ); + _cacheRefreshTimer = Timer(_taskStatusCacheRefreshDelay, () { + _cacheRefreshTimer = null; + if (!mounted) return; + debugPrint( + '[TaskCenter][Page] refresh after cache window reason=$reason', + ); + unawaited(_loadTasks()); + }); + } + @override Widget build(BuildContext context) { final dailyTasks = _sectionTasks(isNewcomer: false); @@ -131,7 +157,7 @@ class _TaskPageState extends State child: RefreshIndicator( color: const Color(0xFF16DBB2), backgroundColor: const Color(0xFF06342A), - onRefresh: _loadTasks, + onRefresh: _handleManualRefresh, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -355,6 +381,7 @@ class _TaskPageState extends State if (opened && mounted) { debugPrint('[TaskCenter][Page] refresh after jump return'); unawaited(_loadTasks()); + _scheduleTaskStatusCacheRefresh('jump return'); } return; } diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index bc25fe5..847b0ad 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -21,8 +21,6 @@ import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:provider/provider.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; -import 'package:yumi/app/routes/sc_routes.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; @@ -2653,6 +2651,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { type: SCRoomMsgType.systemTips, ), ); + unawaited( + rtmProvider?.loadRoomHistoryMessages(groupId, count: 20) ?? + Future.value(), + ); await Future.wait([microphoneListFuture, onlineUsersFuture]); if (!_isActiveRoomStartup(entryRequestSerial, roomId)) { @@ -2959,6 +2961,26 @@ class RealTimeCommunicationManager extends ChangeNotifier { return result; } + void sendRoomTextMessage(String message) { + final normalizedMessage = message.trim(); + final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? ""; + if (normalizedMessage.isEmpty || groupId.isEmpty) { + return; + } + unawaited( + rtmProvider?.dispatchMessage( + Msg( + groupId: groupId, + msg: normalizedMessage, + type: SCRoomMsgType.text, + role: currenRoom?.entrants?.roles ?? "", + user: AccountStorage().getCurrentUser()?.userProfile, + ), + ) ?? + Future.value(), + ); + } + void _refreshRoomRedPacketListAfterEntry() { final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; if (roomId.isEmpty) { @@ -3065,10 +3087,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _clearData(); if (!isLogout && navigationContext != null) { - SCNavigatorUtils.popUntil( - navigationContext, - ModalRoute.withName(SCRoutes.home), - ); + VoiceRoomRoute.popVoiceRoomToPrevious(navigationContext); } final agoraLeaveTask = _leaveAgoraRoomForExit(); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index c9c69d6..69fee76 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -1029,6 +1029,83 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } + Future loadRoomHistoryMessages(String groupId, {int count = 20}) async { + final normalizedGroupId = groupId.trim(); + if (normalizedGroupId.isEmpty) { + return; + } + try { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .getGroupHistoryMessageList( + groupID: normalizedGroupId, + count: count < 10 ? 10 : count, + lastMsgID: null, + ); + final messages = result.data ?? const []; + debugPrint( + '[RoomHistory][IM] loaded group=$normalizedGroupId count=${messages.length}', + ); + for (final message in messages.reversed) { + _addRoomHistoryMessage(normalizedGroupId, message); + } + } catch (error) { + debugPrint( + '[RoomHistory][IM] load failed group=$normalizedGroupId error=$error', + ); + } + } + + void _addRoomHistoryMessage(String groupId, V2TimMessage message) { + try { + final customData = message.customElem?.data; + if (customData == null || customData.trim().isEmpty) { + return; + } + final decoded = json.decode(customData); + if (decoded is! Map) { + return; + } + final data = decoded.map((key, value) => MapEntry(key.toString(), value)); + if (data['type'] == SCRoomMsgType.roomRedPacket) { + final packetData = _broadcastPayloadMap(data['data']); + if (packetData.isEmpty) { + return; + } + if (!_isSameRoomRedPacketRegion(packetData)) { + return; + } + RoomRedPacketPendingCache.instance.upsertPayload(packetData); + addMsg(_buildRoomRedPacketMsg(groupId, packetData)); + return; + } + + final msg = Msg.fromJson(data); + if ((msg.groupId ?? '').trim().isEmpty) { + msg.groupId = groupId; + } + if (_shouldAddRoomHistoryMessage(msg)) { + addMsg(msg); + } + } catch (error) { + debugPrint( + '[RoomHistory][IM] skip malformed msg=${message.msgID ?? ""} error=$error', + ); + } + } + + bool _shouldAddRoomHistoryMessage(Msg msg) { + switch (msg.type) { + case SCRoomMsgType.text: + case SCRoomMsgType.image: + return true; + case SCRoomMsgType.emoticons: + return (msg.number ?? -1) < 0; + default: + return false; + } + } + addMsg(Msg msg) { final mergedGiftMsg = _mergeGiftMessageIfNeeded(msg); if (mergedGiftMsg != null) { @@ -1572,19 +1649,20 @@ class RealTimeMessagingManager extends ChangeNotifier { } bool _isCurrentVoiceRoom(String roomId) { + final currentRoomId = _currentVoiceRoomId(); + return currentRoomId.isNotEmpty && currentRoomId == roomId.trim(); + } + + String _currentVoiceRoomId() { final currentContext = context; - if (roomId.trim().isEmpty || - currentContext == null || - !currentContext.mounted) { - return false; + if (currentContext == null || !currentContext.mounted) { + return ''; } - final currentRoomId = - Provider.of( + return Provider.of( currentContext, listen: false, ).currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; - return currentRoomId.isNotEmpty && currentRoomId == roomId.trim(); } Map _broadcastPayloadMap(dynamic data) { @@ -1655,12 +1733,15 @@ class RealTimeMessagingManager extends ChangeNotifier { if (currentContext == null || !currentContext.mounted) { return; } + final payloadMap = _broadcastPayloadMap(payload); + final payloadRoomId = _payloadText(payloadMap['roomId']); + if (payloadRoomId.isNotEmpty && !_isCurrentVoiceRoom(payloadRoomId)) { + return; + } if (_isCurrentUserRoomRedPacketPayload(payload)) { return; } - final dialogData = RoomRedPacketUiData.fromJson( - _broadcastPayloadMap(payload), - ); + final dialogData = RoomRedPacketUiData.fromJson(payloadMap); if (!dialogData.hasPacketId || !dialogData.isOpenable || RoomRedPacketOpenDialog.isClaimedLocally(dialogData.packetId)) { @@ -1811,11 +1892,9 @@ class RealTimeMessagingManager extends ChangeNotifier { : '', priority: 1000, ); - if (msg.roomId == - Provider.of( - context!, - listen: false, - ).currenRoom?.roomProfile?.roomProfile?.id) { + final packetRoomId = _payloadText(fData["roomId"]); + if (packetRoomId.isNotEmpty && + packetRoomId == _currentVoiceRoomId()) { final rtcProvider = Provider.of( context!, listen: false, @@ -1894,6 +1973,11 @@ class RealTimeMessagingManager extends ChangeNotifier { number: fData["totalCount"], priority: 1000, ); + final packetRoomId = _payloadText(fData["roomId"]); + if (packetRoomId.isNotEmpty && + packetRoomId != _currentVoiceRoomId()) { + return; + } Provider.of( context!, listen: false, diff --git a/lib/shared/business_logic/models/res/sc_entry_popup_res.dart b/lib/shared/business_logic/models/res/sc_entry_popup_res.dart index 042612c..eaabcd4 100644 --- a/lib/shared/business_logic/models/res/sc_entry_popup_res.dart +++ b/lib/shared/business_logic/models/res/sc_entry_popup_res.dart @@ -13,8 +13,8 @@ class SCEntryPopupRes { final map = json is Map ? json : const {}; final queue = (map['queue'] as List? ?? const []) - .map((item) => item.toString()) - .where((item) => item.trim().isNotEmpty) + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) .toList(); final popups = {}; for (final entry in map.entries) { diff --git a/lib/shared/data_sources/sources/local/floating_screen_manager.dart b/lib/shared/data_sources/sources/local/floating_screen_manager.dart index 57e008b..ecbefac 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -560,6 +560,9 @@ class OverlayManager { // 区域红包在当前房间仍然需要展示;区域礼物当前房间由 RTM 层跳过,避免和房间 IM 礼物飘屏重复。 return message.type == 4; } + if (message.type == 4 && rtcProvider.shouldShowRoomVisualEffects) { + return true; + } if (SCGlobalConfig.isFloatingBroadcastRoomOnly) { return false; } diff --git a/lib/shared/tools/sc_entry_popup_coordinator.dart b/lib/shared/tools/sc_entry_popup_coordinator.dart index 930e7f5..35958d5 100644 --- a/lib/shared/tools/sc_entry_popup_coordinator.dart +++ b/lib/shared/tools/sc_entry_popup_coordinator.dart @@ -38,34 +38,57 @@ class SCEntryPopupCoordinator { BuildContext context, { SCEntryPopupRootTabSelector? onSelectRootTab, }) async { - if (_isRunning || !context.mounted || AccountStorage().getToken().isEmpty) { + if (_isRunning) { + _log('skip reason=running'); + return; + } + if (!context.mounted) { + _log('skip reason=context-unmounted'); + return; + } + if (AccountStorage().getToken().isEmpty) { + _log('skip reason=token-empty'); return; } final now = DateTime.now(); final lastRequestAt = _lastRequestAt; if (lastRequestAt != null && now.difference(lastRequestAt) < _requestMinGap) { + _log('skip reason=throttled'); return; } _isRunning = true; _lastRequestAt = now; try { + _log('request start'); final response = await SCConfigRepositoryImp().entryPopup(); + _log( + 'response requestId=${response.requestId} ' + 'queue=${response.queue} popups=${response.popups.keys.toList()}', + ); final popup = _pickDisplayablePopup(response); if (popup == null) { + _log('skip reason=no-displayable-popup'); return; } + final imageUrl = normalizeImageResourceUrl(popup.image); final imageReady = await warmImageResource( - popup.image, + imageUrl, logicalWidth: 326, timeout: const Duration(seconds: 8), ); if (!imageReady || !context.mounted) { + _log( + 'skip reason=image-not-ready key=${popup.key} ' + 'image=${_clip(imageUrl)} mounted=${context.mounted}', + ); return; } + _log('show key=${popup.key} popupId=${popup.popupId}'); await SCEntryPopupDialog.show( context, popup: popup, + imageUrl: imageUrl, onDisplayed: () { unawaited(_markShown(popup)); }, @@ -81,29 +104,64 @@ class SCEntryPopupCoordinator { } static SCEntryPopupItem? _pickDisplayablePopup(SCEntryPopupRes response) { + final queuedKeys = {}; for (final popupKey in response.queue) { + queuedKeys.add(popupKey); final popup = response.popups[popupKey]; - if (popup == null || !_canShow(popup)) { + if (popup == null) { + _log('skip candidate key=$popupKey reason=missing-popup'); continue; } + final reason = _cannotShowReason(popup); + if (reason != null) { + _log('skip candidate key=${popup.key} reason=$reason'); + continue; + } + _log('candidate key=${popup.key} source=queue'); + return popup; + } + + final fallbackPopups = + response.popups.values + .where((popup) => !queuedKeys.contains(popup.key)) + .toList() + ..sort((left, right) => right.priority.compareTo(left.priority)); + for (final popup in fallbackPopups) { + final reason = _cannotShowReason(popup); + if (reason != null) { + _log('skip fallback key=${popup.key} reason=$reason'); + continue; + } + _log('candidate key=${popup.key} source=priority-fallback'); return popup; } return null; } - static bool _canShow(SCEntryPopupItem popup) { - if (!popup.value || popup.image.trim().isEmpty) { - return false; + static String? _cannotShowReason(SCEntryPopupItem popup) { + if (!popup.value) { + return 'disabled${popup.reason.isEmpty ? '' : '(${popup.reason})'}'; + } + if (popup.image.trim().isEmpty) { + return 'image-empty'; } if (popup.limit <= 0) { - return true; + return null; } - final lastShownAt = DataPersistence.getInt(_shownStorageKey(popup)); + final storageKey = _shownStorageKey(popup); + final lastShownAt = DataPersistence.getInt(storageKey); if (lastShownAt <= 0) { - return true; + return null; } final nowMs = DateTime.now().millisecondsSinceEpoch; - return nowMs - lastShownAt >= Duration(days: popup.limit).inMilliseconds; + final elapsedMs = nowMs - lastShownAt; + final limitMs = Duration(days: popup.limit).inMilliseconds; + if (elapsedMs >= limitMs) { + return null; + } + final remainHours = + ((limitMs - elapsedMs) / Duration.millisecondsPerHour).ceil(); + return 'limited key=$storageKey remainHours=$remainHours'; } static Future _markShown(SCEntryPopupItem popup) { @@ -412,4 +470,15 @@ class SCEntryPopupCoordinator { } return ''; } + + static void _log(String message) { + debugPrint('[EntryPopup] $message'); + } + + static String _clip(String value, {int maxLength = 180}) { + if (value.length <= maxLength) { + return value; + } + return '${value.substring(0, maxLength)}...'; + } } diff --git a/lib/shared/tools/sc_network_image_utils.dart b/lib/shared/tools/sc_network_image_utils.dart index b841d7a..7a8ce4b 100644 --- a/lib/shared/tools/sc_network_image_utils.dart +++ b/lib/shared/tools/sc_network_image_utils.dart @@ -38,6 +38,35 @@ Map? buildNetworkImageHeaders(String url) { return headers; } +String normalizeImageResourceUrl(String resource) { + final trimmed = resource.trim(); + if (trimmed.startsWith('//')) { + final apiScheme = Uri.tryParse(SCGlobalConfig.apiHost)?.scheme; + final scheme = apiScheme == 'http' ? 'http' : 'https'; + return '$scheme:$trimmed'; + } + if (_isServerRelativeImagePath(trimmed)) { + final baseUri = Uri.tryParse(SCGlobalConfig.apiHost); + if (baseUri != null && baseUri.hasScheme && baseUri.host.isNotEmpty) { + final path = trimmed.startsWith('/') ? trimmed : '/$trimmed'; + return baseUri.resolve(path).toString(); + } + } + return trimmed; +} + +bool _isServerRelativeImagePath(String value) { + final lowerValue = value.toLowerCase(); + return lowerValue.startsWith('/external/') || + lowerValue.startsWith('external/') || + lowerValue.startsWith('/go/') || + lowerValue.startsWith('go/') || + lowerValue.startsWith('/static/') || + lowerValue.startsWith('static/') || + lowerValue.startsWith('/upload') || + lowerValue.startsWith('upload'); +} + int? _resolveImageProviderCacheDimension(double? logicalSize) { if (logicalSize == null || !logicalSize.isFinite || logicalSize <= 0) { return null; @@ -54,18 +83,19 @@ ImageProvider buildCachedImageProvider( double? logicalWidth, double? logicalHeight, }) { + final normalizedUrl = normalizeImageResourceUrl(url); late final ImageProvider provider; - if (url.startsWith("http")) { + if (normalizedUrl.startsWith("http")) { provider = ExtendedNetworkImageProvider( - url, - headers: buildNetworkImageHeaders(url), + normalizedUrl, + headers: buildNetworkImageHeaders(normalizedUrl), cache: true, cacheMaxAge: const Duration(days: 7), ); - } else if (url.startsWith('/')) { - provider = FileImage(File(url)); + } else if (normalizedUrl.startsWith('/')) { + provider = FileImage(File(normalizedUrl)); } else { - provider = AssetImage(url); + provider = AssetImage(normalizedUrl); } return ResizeImage.resizeIfNeeded( _resolveImageProviderCacheDimension(logicalWidth), diff --git a/lib/ui_kit/widgets/entry_popup/entry_popup_dialog.dart b/lib/ui_kit/widgets/entry_popup/entry_popup_dialog.dart index c6e38c4..adb078f 100644 --- a/lib/ui_kit/widgets/entry_popup/entry_popup_dialog.dart +++ b/lib/ui_kit/widgets/entry_popup/entry_popup_dialog.dart @@ -11,6 +11,7 @@ class SCEntryPopupDialog extends StatefulWidget { super.key, required this.popup, required this.onTap, + this.imageUrl, this.onDisplayed, }); @@ -18,12 +19,14 @@ class SCEntryPopupDialog extends StatefulWidget { final SCEntryPopupItem popup; final VoidCallback onTap; + final String? imageUrl; final VoidCallback? onDisplayed; static Future show( BuildContext context, { required SCEntryPopupItem popup, required VoidCallback onTap, + String? imageUrl, VoidCallback? onDisplayed, }) async { SmartDialog.dismiss(tag: dialogTag); @@ -37,6 +40,7 @@ class SCEntryPopupDialog extends StatefulWidget { (_) => SCEntryPopupDialog( popup: popup, onTap: onTap, + imageUrl: imageUrl, onDisplayed: onDisplayed, ), ); @@ -65,7 +69,7 @@ class _SCEntryPopupDialogState extends State { Widget build(BuildContext context) { final screenWidth = MediaQuery.sizeOf(context).width; final dialogWidth = math.min(screenWidth - 42.w, 326.w); - final imageUrl = widget.popup.image.trim(); + final imageUrl = (widget.imageUrl ?? widget.popup.image).trim(); return Material( color: Colors.transparent, diff --git a/lib/ui_kit/widgets/room/exit_min_room_page.dart b/lib/ui_kit/widgets/room/exit_min_room_page.dart index de72e4a..847e9de 100644 --- a/lib/ui_kit/widgets/room/exit_min_room_page.dart +++ b/lib/ui_kit/widgets/room/exit_min_room_page.dart @@ -6,8 +6,8 @@ import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:provider/provider.dart'; -import 'package:yumi/app/routes/sc_routes.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/modules/room/voice_room_route.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/gift/gift_animation_manager.dart'; import 'package:yumi/services/music/room_music_manager.dart'; @@ -86,10 +86,7 @@ class _ExitMinRoomPageState extends State { unawaited(context.read().stopForRoomExit()); giftAnimationManager.cleanupAnimationResources(); SCFloatIchart().show(); - SCNavigatorUtils.popUntil( - context, - ModalRoute.withName(SCRoutes.home), - ); + VoiceRoomRoute.popVoiceRoomToPrevious(context); }, ), SCDebounceWidget( diff --git a/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart index 88eb9b8..cbcad56 100644 --- a/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart +++ b/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart @@ -313,13 +313,11 @@ class _FloatingRoomRedenvelopeScreenWidgetState } return; } - SCRoomUtils.goRoom( - roomId, - currentContext, - needOpenRedenvelope: true, - redPackId: widget.message.toUserId ?? "", - fromFloting: true, - ); + if ((currentRoomId ?? '').trim().isEmpty) { + rtcProvider.joinVoiceRoomSession(currentContext, roomId); + return; + } + SCRoomUtils.goRoom(roomId, currentContext, fromFloting: true); } String _senderNameForFloating() { diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_history_page.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_history_page.dart index 8e4b610..ac7ce12 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_history_page.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_history_page.dart @@ -247,8 +247,8 @@ class _RoomRedPacketHistoryTile extends StatelessWidget { switch (status) { case _RoomRedPacketHistoryStatus.sent: return localizations.roomRedPacketHistoryStatusSent; - case _RoomRedPacketHistoryStatus.pending: - return localizations.roomRedPacketHistoryStatusPending; + case _RoomRedPacketHistoryStatus.finished: + return localizations.roomRedPacketHistoryStatusFinished; case _RoomRedPacketHistoryStatus.returned: return localizations.roomRedPacketHistoryStatusReturned; } @@ -258,8 +258,8 @@ class _RoomRedPacketHistoryTile extends StatelessWidget { switch (status) { case _RoomRedPacketHistoryStatus.sent: return const Color(0xFFFF4A3C); - case _RoomRedPacketHistoryStatus.pending: - return const Color(0xFFFFC857); + case _RoomRedPacketHistoryStatus.finished: + return const Color(0xFFFF4A3C); case _RoomRedPacketHistoryStatus.returned: return const Color(0xFF66D7A3); } @@ -365,11 +365,19 @@ class _RoomRedPacketHistoryItem { SCRoomRedPacketSentRecordRes record, ) { final status = record.status.toLowerCase(); - if (status.contains('return') || status.contains('refund')) { + if (record.refundAmount > 0 || + status.contains('return') || + status.contains('refund')) { return _RoomRedPacketHistoryStatus.returned; } - if (status.contains('pending') || status.contains('active')) { - return _RoomRedPacketHistoryStatus.pending; + final isFinishedByStatus = + status.contains('finish') || + status.contains('complete') || + status.contains('sold') || + status.contains('empty'); + final isFinishedByCount = record.totalCount > 0 && record.remainCount <= 0; + if (isFinishedByStatus || isFinishedByCount) { + return _RoomRedPacketHistoryStatus.finished; } return _RoomRedPacketHistoryStatus.sent; } @@ -399,4 +407,4 @@ String _formatHistoryTime(String raw) { String _bidiSafeLtr(String value) => '\u200E$value\u200E'; -enum _RoomRedPacketHistoryStatus { sent, pending, returned } +enum _RoomRedPacketHistoryStatus { sent, finished, returned } diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart index 3cb9085..360663d 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart @@ -24,6 +24,7 @@ class RoomRedPacketOpenDialog extends StatefulWidget { static const String dialogTag = 'showRoomRedPacketOpenDialog'; static final Map _claimedAmountByPacketId = {}; + static final Set _unavailablePacketIds = {}; static final ValueNotifier claimedStateNotifier = ValueNotifier(0); final RoomRedPacketUiData data; @@ -85,6 +86,10 @@ class RoomRedPacketOpenDialog extends StatefulWidget { return _claimedAmountByPacketId.containsKey(_normalizePacketId(packetId)); } + static bool isUnavailableLocally(String packetId) { + return _unavailablePacketIds.contains(_normalizePacketId(packetId)); + } + static void rememberClaimed(String packetId, num amount) { final normalizedPacketId = _normalizePacketId(packetId); if (normalizedPacketId.isEmpty) { @@ -95,11 +100,22 @@ class RoomRedPacketOpenDialog extends StatefulWidget { claimedStateNotifier.value++; } + static void rememberUnavailable(String packetId) { + final normalizedPacketId = _normalizePacketId(packetId); + if (normalizedPacketId.isEmpty) { + return; + } + _unavailablePacketIds.add(normalizedPacketId); + RoomRedPacketPendingCache.instance.removePacket(normalizedPacketId); + claimedStateNotifier.value++; + } + static void clearClaimedState() { - if (_claimedAmountByPacketId.isEmpty) { + if (_claimedAmountByPacketId.isEmpty && _unavailablePacketIds.isEmpty) { return; } _claimedAmountByPacketId.clear(); + _unavailablePacketIds.clear(); claimedStateNotifier.value++; } @@ -154,12 +170,7 @@ class RoomRedPacketOpenDialog extends StatefulWidget { static String _normalizePacketId(String packetId) => packetId.trim(); static bool _shouldIncludeRoomPacket(RoomRedPacketUiData data) { - final normalizedStatus = data.statusDesc.toLowerCase(); - final explicitSoldOut = - data.status == 2 || - normalizedStatus.contains('finish') || - normalizedStatus.contains('sold'); - return !data.grabbed && !data.isExpired && !explicitSoldOut; + return !data.grabbed && !data.isExpired && !data.isSoldOut; } static void _addPacketIfAbsent( @@ -709,6 +720,7 @@ class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> { } final amount = result.amount ?? widget.data.mockClaimAmount; RoomRedPacketOpenDialog.rememberClaimed(widget.data.packetId, amount); + _sendClaimSuccessRoomMessage(amount); setState(() { _claimAmount = amount; _state = _RoomRedPacketOpenState.opened; @@ -719,6 +731,10 @@ class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> { return; } final code = roomRedPacketErrorCode(error); + if (code == 'voice_room_red_packet_expired' || + code == 'voice_room_red_packet_finished') { + RoomRedPacketOpenDialog.rememberUnavailable(widget.data.packetId); + } setState(() { if (code == 'voice_room_red_packet_not_started') { _state = _RoomRedPacketOpenState.waiting; @@ -755,6 +771,25 @@ class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> { } catch (_) {} } + void _sendClaimSuccessRoomMessage(num amount) { + final localizations = SCAppLocalizations.of(context); + if (localizations == null) { + return; + } + final senderName = + widget.data.senderName.trim().isNotEmpty + ? widget.data.senderName.trim() + : localizations.redEnvelope; + final message = localizations.roomRedPacketReceivedMessage( + _formatNumber(amount.round()), + senderName, + ); + Provider.of( + context, + listen: false, + ).sendRoomTextMessage(message); + } + bool get _isLocalPreviewPacket { final packetId = widget.data.packetId.trim(); return packetId.isEmpty || diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart index 10372b9..b56b475 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart @@ -82,6 +82,10 @@ class RoomRedPacketPendingCache extends ChangeNotifier { .toList(growable: false); } + void pruneExpired() { + _pruneExpired(); + } + void removePacket(String packetId) { final normalizedPacketId = packetId.trim(); if (normalizedPacketId.isEmpty) { diff --git a/lib/ui_kit/widgets/room/room_play_widget.dart b/lib/ui_kit/widgets/room/room_play_widget.dart index 28c151d..fabb902 100644 --- a/lib/ui_kit/widgets/room/room_play_widget.dart +++ b/lib/ui_kit/widgets/room/room_play_widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:provider/provider.dart'; @@ -51,9 +53,112 @@ class _RoomPlayWidgetState extends State { } } -class _RoomRedPacketFloatingEntry extends StatelessWidget { +class _RoomRedPacketFloatingEntry extends StatefulWidget { const _RoomRedPacketFloatingEntry(); + @override + State<_RoomRedPacketFloatingEntry> createState() => + _RoomRedPacketFloatingEntryState(); +} + +class _RoomRedPacketFloatingEntryState + extends State<_RoomRedPacketFloatingEntry> { + static const int _listRefreshIntervalSeconds = 5; + + Timer? _entryRefreshTimer; + bool _refreshingList = false; + int _elapsedSeconds = 0; + String _verifiedRoomId = ''; + int _verifiedRoomRefreshCount = 0; + Set _verifiedKnownPacketIds = const {}; + Set _verifiedVisiblePacketIds = const {}; + + @override + void initState() { + super.initState(); + _entryRefreshTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _onEntryTimerTick(), + ); + } + + @override + void dispose() { + _entryRefreshTimer?.cancel(); + super.dispose(); + } + + void _onEntryTimerTick() { + RoomRedPacketPendingCache.instance.pruneExpired(); + if (!mounted) { + return; + } + setState(() {}); + _elapsedSeconds++; + if (_elapsedSeconds % _listRefreshIntervalSeconds == 0) { + _refreshRoomRedPacketList(); + } + } + + void _refreshRoomRedPacketList() { + if (_refreshingList || !mounted) { + return; + } + try { + final rtcProvider = Provider.of(context, listen: false); + final currentRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; + if (currentRoomId.isEmpty) { + return; + } + _refreshingList = true; + unawaited( + rtcProvider + .loadRoomRedPacketList(1) + .then((result) { + final serverPackets = + result.map(RoomRedPacketUiData.fromListRes).toList(); + final serverPacketIds = + serverPackets + .map((item) => item.packetId.trim()) + .where((packetId) => packetId.isNotEmpty) + .toSet(); + final visiblePacketIds = + serverPackets + .map(RoomRedPacketOpenDialog.withLocalClaimState) + .where(_shouldShowRoomRedPacketEntry) + .map((item) => item.packetId.trim()) + .where((packetId) => packetId.isNotEmpty) + .toSet(); + if (!mounted) { + return result; + } + setState(() { + if (_verifiedRoomId == currentRoomId) { + _verifiedRoomRefreshCount++; + _verifiedKnownPacketIds = { + ..._verifiedKnownPacketIds, + ...serverPacketIds, + }; + } else { + _verifiedRoomId = currentRoomId; + _verifiedRoomRefreshCount = 1; + _verifiedKnownPacketIds = serverPacketIds; + } + _verifiedVisiblePacketIds = visiblePacketIds; + }); + return result; + }) + .catchError((_) => rtcProvider.redPacketList) + .whenComplete(() { + _refreshingList = false; + }), + ); + } catch (_) { + _refreshingList = false; + } + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -67,6 +172,10 @@ class _RoomRedPacketFloatingEntry extends StatelessWidget { final snapshot = _RoomRedPacketEntrySnapshot.fromProviders( rtcProvider: rtcProvider, rtmProvider: rtmProvider, + verifiedRoomId: _verifiedRoomId, + verifiedRoomRefreshCount: _verifiedRoomRefreshCount, + verifiedKnownPacketIds: _verifiedKnownPacketIds, + verifiedVisiblePacketIds: _verifiedVisiblePacketIds, ); if (snapshot.packets.isEmpty) { return const SizedBox.shrink(); @@ -76,6 +185,10 @@ class _RoomRedPacketFloatingEntry extends StatelessWidget { final freshSnapshot = _RoomRedPacketEntrySnapshot.fromProviders( rtcProvider: Provider.of(context, listen: false), rtmProvider: Provider.of(context, listen: false), + verifiedRoomId: _verifiedRoomId, + verifiedRoomRefreshCount: _verifiedRoomRefreshCount, + verifiedKnownPacketIds: _verifiedKnownPacketIds, + verifiedVisiblePacketIds: _verifiedVisiblePacketIds, ); if (freshSnapshot.packets.isEmpty) { return; @@ -145,19 +258,41 @@ class _RoomRedPacketEntrySnapshot { factory _RoomRedPacketEntrySnapshot.fromProviders({ required RtcProvider rtcProvider, required RtmProvider rtmProvider, + required String verifiedRoomId, + required int verifiedRoomRefreshCount, + required Set verifiedKnownPacketIds, + required Set verifiedVisiblePacketIds, }) { final packets = []; + final hiddenPacketIds = {}; final currentRoomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; + final shouldTrustVerifiedAbsence = + currentRoomId.isNotEmpty && + verifiedRoomId == currentRoomId && + verifiedRoomRefreshCount >= 2; for (final packet in rtcProvider.redPacketList.map( RoomRedPacketUiData.fromListRes, )) { - _addVisiblePacket(packets, packet); + _addVisiblePacket( + packets, + packet, + hiddenPacketIds: hiddenPacketIds, + authoritative: true, + ); } for (final packet in RoomRedPacketPendingCache.instance.packetsForRoom( currentRoomId, )) { - _addVisiblePacket(packets, packet); + _addVisiblePacket( + packets, + packet, + hiddenPacketIds: hiddenPacketIds, + verifiedKnownPacketIds: + shouldTrustVerifiedAbsence ? verifiedKnownPacketIds : null, + verifiedVisiblePacketIds: + shouldTrustVerifiedAbsence ? verifiedVisiblePacketIds : null, + ); } for (final msg in rtmProvider.roomChatMsgList) { if (msg.type != SCRoomMsgType.roomRedPacket) { @@ -169,6 +304,11 @@ class _RoomRedPacketEntrySnapshot { payload: msg.msg, fallbackUser: msg.user, ), + hiddenPacketIds: hiddenPacketIds, + verifiedKnownPacketIds: + shouldTrustVerifiedAbsence ? verifiedKnownPacketIds : null, + verifiedVisiblePacketIds: + shouldTrustVerifiedAbsence ? verifiedVisiblePacketIds : null, ); } final signature = packets @@ -197,13 +337,40 @@ class _RoomRedPacketEntrySnapshot { void _addVisiblePacket( List packets, - RoomRedPacketUiData packet, -) { + RoomRedPacketUiData packet, { + Set? hiddenPacketIds, + Set? verifiedKnownPacketIds, + Set? verifiedVisiblePacketIds, + bool authoritative = false, +}) { final normalizedPacket = RoomRedPacketOpenDialog.withLocalClaimState(packet); - if (!_shouldShowRoomRedPacketEntry(normalizedPacket)) { + final packetId = normalizedPacket.packetId.trim(); + if (packetId.isNotEmpty && + RoomRedPacketOpenDialog.isUnavailableLocally(packetId)) { + if (authoritative) { + hiddenPacketIds?.add(packetId); + } + return; + } + if (!authoritative && + packetId.isNotEmpty && + (hiddenPacketIds?.contains(packetId) ?? false)) { + return; + } + if (!authoritative && + packetId.isNotEmpty && + verifiedKnownPacketIds != null && + verifiedVisiblePacketIds != null && + verifiedKnownPacketIds.contains(packetId) && + !verifiedVisiblePacketIds.contains(packetId)) { + return; + } + if (!_shouldShowRoomRedPacketEntry(normalizedPacket)) { + if (authoritative && packetId.isNotEmpty) { + hiddenPacketIds?.add(packetId); + } return; } - final packetId = normalizedPacket.packetId.trim(); if (packetId.isNotEmpty && packets.any((item) => item.packetId.trim() == packetId)) { return; @@ -212,10 +379,5 @@ void _addVisiblePacket( } bool _shouldShowRoomRedPacketEntry(RoomRedPacketUiData item) { - final normalizedStatus = item.statusDesc.toLowerCase(); - final explicitSoldOut = - item.status == 2 || - normalizedStatus.contains('finish') || - normalizedStatus.contains('sold'); - return !item.grabbed && !item.isExpired && !explicitSoldOut; + return !item.grabbed && !item.isExpired && !item.isSoldOut; } diff --git a/需求进度.md b/需求进度.md index cd71e4c..1fcd0d0 100644 --- a/需求进度.md +++ b/需求进度.md @@ -550,6 +550,9 @@ - 已补齐红包领取记录页的发包摘要:从发红包记录页点击进入后,顶部固定展示该红包图标、红包数量、发送时间、发出金额和退款金额;即使领取列表为空仍展示红包金额,空态只作为领取人列表区域提示。 - 已新增红包发送余额不足充值引导:发送接口返回 `voice_room_red_packet_insufficient_balance` 时关闭红包发送面板,改为弹出约半屏高度的底部充值弹窗,展示当前金币余额和充值按钮;点击充值复用 `me/wallet` 的 `WalletRoute.recharge` 进入充值页,返回后刷新用户余额。 - 已按反馈统一红包入口图标:房间右侧待领红包入口去掉黑底和金色外圈,直接展示放大的红包图标,尺寸与游戏入口接近;房间菜单内红包项也从白色线性图标改为同一张红包图标。 +- 已继续修正红包跨房间与消息链路:同语区区域红包飘屏现在允许在其它语音房内展示;首页点击红包飘屏 `GO` 会直接进房,不再先弹确认或自动弹领取层;进房加入 IM 群后会拉取最近至少 10 条、当前按 20 条房间历史自定义消息并补入红包聊天记录;红包领取成功后,领取者会自动发送一条“从某用户 Lucky Pack 领取多少金币”的多语言房内 IM 文本消息。 +- 已继续修正红包入口状态刷新:房间右侧红包入口现在每秒清理过期缓存、每 5 秒刷新当前房间红包列表,并以后端列表里“已领/过期/抢完”的 packetId 作为权威隐藏来源;连续两次成功刷新后,若某红包曾在后端列表出现、但已不在后端可领列表中,也会过滤旧聊天消息或区域飘屏缓存,领取时遇到“已过期/已抢完”也会立刻本地隐藏入口,避免已不可领红包重新显示;同语区红包飘屏仍可跨房间展示,但即时红包领取弹层只会在 payload `roomId` 等于当前语音房时自动打开。 +- 已收口红包发送记录页状态展示:不再显示 `Pending/等待中`,后端 `ACTIVE/PENDING` 等中间态统一展示为已发出;只保留已退回、已领完、已发出三类状态,并补齐多语言的“已领完”文案。 ## 已知问题 - 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji` 与 `monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。