This commit is contained in:
roxy 2026-05-08 22:52:12 +08:00
parent 35c05a9123
commit 01ba9d384b
22 changed files with 555 additions and 82 deletions

View File

@ -343,6 +343,7 @@
"roomRedPacketHistoryTitle": "سجل إرسال حزم الحظ", "roomRedPacketHistoryTitle": "سجل إرسال حزم الحظ",
"roomRedPacketHistoryItemTitle": "حزمة حظ ({1} مظروفاً)", "roomRedPacketHistoryItemTitle": "حزمة حظ ({1} مظروفاً)",
"roomRedPacketHistoryStatusSent": "تم الإرسال", "roomRedPacketHistoryStatusSent": "تم الإرسال",
"roomRedPacketHistoryStatusFinished": "تم استلامها بالكامل",
"roomRedPacketHistoryStatusPending": "قيد الانتظار", "roomRedPacketHistoryStatusPending": "قيد الانتظار",
"roomRedPacketHistoryStatusReturned": "تم الإرجاع", "roomRedPacketHistoryStatusReturned": "تم الإرجاع",
"roomRedPacketHistoryRefund": "المسترد: {1}", "roomRedPacketHistoryRefund": "المسترد: {1}",

View File

@ -191,6 +191,7 @@
"roomRedPacketHistoryTitle": "লাকি প্যাক পাঠানোর ইতিহাস", "roomRedPacketHistoryTitle": "লাকি প্যাক পাঠানোর ইতিহাস",
"roomRedPacketHistoryItemTitle": "লাকি প্যাক ({1} খাম)", "roomRedPacketHistoryItemTitle": "লাকি প্যাক ({1} খাম)",
"roomRedPacketHistoryStatusSent": "পাঠানো হয়েছে", "roomRedPacketHistoryStatusSent": "পাঠানো হয়েছে",
"roomRedPacketHistoryStatusFinished": "সব গ্রহণ করা হয়েছে",
"roomRedPacketHistoryStatusPending": "অপেক্ষায়", "roomRedPacketHistoryStatusPending": "অপেক্ষায়",
"roomRedPacketHistoryStatusReturned": "ফেরত দেওয়া হয়েছে", "roomRedPacketHistoryStatusReturned": "ফেরত দেওয়া হয়েছে",
"roomRedPacketHistoryRefund": "ফেরত: {1}", "roomRedPacketHistoryRefund": "ফেরত: {1}",

View File

@ -162,6 +162,7 @@
"roomRedPacketHistoryTitle": "Lucky Pack Delivery History", "roomRedPacketHistoryTitle": "Lucky Pack Delivery History",
"roomRedPacketHistoryItemTitle": "Lucky Bag ({1} red envelopes)", "roomRedPacketHistoryItemTitle": "Lucky Bag ({1} red envelopes)",
"roomRedPacketHistoryStatusSent": "Sent", "roomRedPacketHistoryStatusSent": "Sent",
"roomRedPacketHistoryStatusFinished": "Fully claimed",
"roomRedPacketHistoryStatusPending": "Pending", "roomRedPacketHistoryStatusPending": "Pending",
"roomRedPacketHistoryStatusReturned": "Returned", "roomRedPacketHistoryStatusReturned": "Returned",
"roomRedPacketHistoryRefund": "Refund: {1}", "roomRedPacketHistoryRefund": "Refund: {1}",

View File

@ -151,6 +151,7 @@
"roomRedPacketHistoryTitle": "Şans Paketi Gönderim Geçmişi", "roomRedPacketHistoryTitle": "Şans Paketi Gönderim Geçmişi",
"roomRedPacketHistoryItemTitle": "Şans Paketi ({1} zarf)", "roomRedPacketHistoryItemTitle": "Şans Paketi ({1} zarf)",
"roomRedPacketHistoryStatusSent": "Gönderildi", "roomRedPacketHistoryStatusSent": "Gönderildi",
"roomRedPacketHistoryStatusFinished": "Tamamı alındı",
"roomRedPacketHistoryStatusPending": "Beklemede", "roomRedPacketHistoryStatusPending": "Beklemede",
"roomRedPacketHistoryStatusReturned": "İade edildi", "roomRedPacketHistoryStatusReturned": "İade edildi",
"roomRedPacketHistoryRefund": "İade: {1}", "roomRedPacketHistoryRefund": "İade: {1}",

View File

@ -16,7 +16,7 @@ class SCVariant1Config implements AppConfig {
@override @override
String get apiHost => const String.fromEnvironment( String get apiHost => const String.fromEnvironment(
'API_HOST', '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/ ); // 线 --dart-define=API_HOST http://192.168.110.64:1100/线https://jvapi.haiyihy.com/
@override @override

View File

@ -655,6 +655,9 @@ class SCAppLocalizations {
String get roomRedPacketHistoryStatusSent => String get roomRedPacketHistoryStatusSent =>
translate('roomRedPacketHistoryStatusSent'); translate('roomRedPacketHistoryStatusSent');
String get roomRedPacketHistoryStatusFinished =>
translate('roomRedPacketHistoryStatusFinished');
String get roomRedPacketHistoryStatusPending => String get roomRedPacketHistoryStatusPending =>
translate('roomRedPacketHistoryStatusPending'); translate('roomRedPacketHistoryStatusPending');

View File

@ -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<T?> openRoomEdit<T>( static Future<T?> openRoomEdit<T>(
BuildContext context, { BuildContext context, {
required bool needRestCurrentRoomInfo, required bool needRestCurrentRoomInfo,

View File

@ -24,6 +24,7 @@ class _TaskPageState extends State<TaskPage>
static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png'; 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 _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png';
static const String _taskDefaultAsset = 'sc_images/index/sc_icon_task.png'; static const String _taskDefaultAsset = 'sc_images/index/sc_icon_task.png';
static const Duration _taskStatusCacheRefreshDelay = Duration(seconds: 65);
final SCAccountRepository _repository = SCAccountRepository(); final SCAccountRepository _repository = SCAccountRepository();
final Set<String> _claimedTaskIds = {}; final Set<String> _claimedTaskIds = {};
@ -33,6 +34,7 @@ class _TaskPageState extends State<TaskPage>
bool _loadingTasks = false; bool _loadingTasks = false;
String? _toastMessage; String? _toastMessage;
Timer? _toastTimer; Timer? _toastTimer;
Timer? _cacheRefreshTimer;
PageRoute<dynamic>? _routeObserverRoute; PageRoute<dynamic>? _routeObserverRoute;
@override @override
@ -47,6 +49,7 @@ class _TaskPageState extends State<TaskPage>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this); routeObserver.unsubscribe(this);
_toastTimer?.cancel(); _toastTimer?.cancel();
_cacheRefreshTimer?.cancel();
super.dispose(); super.dispose();
} }
@ -68,6 +71,7 @@ class _TaskPageState extends State<TaskPage>
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
debugPrint('[TaskCenter][Page] refresh on app resumed'); debugPrint('[TaskCenter][Page] refresh on app resumed');
unawaited(_loadTasks()); unawaited(_loadTasks());
_scheduleTaskStatusCacheRefresh('app resumed');
} }
} }
@ -75,6 +79,7 @@ class _TaskPageState extends State<TaskPage>
void didPopNext() { void didPopNext() {
debugPrint('[TaskCenter][Page] refresh on route return'); debugPrint('[TaskCenter][Page] refresh on route return');
unawaited(_loadTasks()); unawaited(_loadTasks());
_scheduleTaskStatusCacheRefresh('route return');
} }
Future<void> _loadTasks() async { Future<void> _loadTasks() async {
@ -113,6 +118,27 @@ class _TaskPageState extends State<TaskPage>
} }
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dailyTasks = _sectionTasks(isNewcomer: false); final dailyTasks = _sectionTasks(isNewcomer: false);
@ -131,7 +157,7 @@ class _TaskPageState extends State<TaskPage>
child: RefreshIndicator( child: RefreshIndicator(
color: const Color(0xFF16DBB2), color: const Color(0xFF16DBB2),
backgroundColor: const Color(0xFF06342A), backgroundColor: const Color(0xFF06342A),
onRefresh: _loadTasks, onRefresh: _handleManualRefresh,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics( physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(), parent: BouncingScrollPhysics(),
@ -355,6 +381,7 @@ class _TaskPageState extends State<TaskPage>
if (opened && mounted) { if (opened && mounted) {
debugPrint('[TaskCenter][Page] refresh after jump return'); debugPrint('[TaskCenter][Page] refresh after jump return');
unawaited(_loadTasks()); unawaited(_loadTasks());
_scheduleTaskStatusCacheRefresh('jump return');
} }
return; return;
} }

View File

@ -21,8 +21,6 @@ import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/app/constants/sc_global_config.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_lk_dialog_util.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.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'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
@ -2653,6 +2651,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
type: SCRoomMsgType.systemTips, type: SCRoomMsgType.systemTips,
), ),
); );
unawaited(
rtmProvider?.loadRoomHistoryMessages(groupId, count: 20) ??
Future<void>.value(),
);
await Future.wait([microphoneListFuture, onlineUsersFuture]); await Future.wait([microphoneListFuture, onlineUsersFuture]);
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) { if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
@ -2959,6 +2961,26 @@ class RealTimeCommunicationManager extends ChangeNotifier {
return result; 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<void>.value(),
);
}
void _refreshRoomRedPacketListAfterEntry() { void _refreshRoomRedPacketListAfterEntry() {
final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
if (roomId.isEmpty) { if (roomId.isEmpty) {
@ -3065,10 +3087,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_clearData(); _clearData();
if (!isLogout && navigationContext != null) { if (!isLogout && navigationContext != null) {
SCNavigatorUtils.popUntil( VoiceRoomRoute.popVoiceRoomToPrevious(navigationContext);
navigationContext,
ModalRoute.withName(SCRoutes.home),
);
} }
final agoraLeaveTask = _leaveAgoraRoomForExit(); final agoraLeaveTask = _leaveAgoraRoomForExit();

View File

@ -1029,6 +1029,83 @@ class RealTimeMessagingManager extends ChangeNotifier {
); );
} }
Future<void> 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 <V2TimMessage>[];
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) { addMsg(Msg msg) {
final mergedGiftMsg = _mergeGiftMessageIfNeeded(msg); final mergedGiftMsg = _mergeGiftMessageIfNeeded(msg);
if (mergedGiftMsg != null) { if (mergedGiftMsg != null) {
@ -1572,19 +1649,20 @@ class RealTimeMessagingManager extends ChangeNotifier {
} }
bool _isCurrentVoiceRoom(String roomId) { bool _isCurrentVoiceRoom(String roomId) {
final currentRoomId = _currentVoiceRoomId();
return currentRoomId.isNotEmpty && currentRoomId == roomId.trim();
}
String _currentVoiceRoomId() {
final currentContext = context; final currentContext = context;
if (roomId.trim().isEmpty || if (currentContext == null || !currentContext.mounted) {
currentContext == null || return '';
!currentContext.mounted) {
return false;
} }
final currentRoomId = return Provider.of<RealTimeCommunicationManager>(
Provider.of<RealTimeCommunicationManager>(
currentContext, currentContext,
listen: false, listen: false,
).currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ).currenRoom?.roomProfile?.roomProfile?.id?.trim() ??
''; '';
return currentRoomId.isNotEmpty && currentRoomId == roomId.trim();
} }
Map<String, dynamic> _broadcastPayloadMap(dynamic data) { Map<String, dynamic> _broadcastPayloadMap(dynamic data) {
@ -1655,12 +1733,15 @@ class RealTimeMessagingManager extends ChangeNotifier {
if (currentContext == null || !currentContext.mounted) { if (currentContext == null || !currentContext.mounted) {
return; return;
} }
final payloadMap = _broadcastPayloadMap(payload);
final payloadRoomId = _payloadText(payloadMap['roomId']);
if (payloadRoomId.isNotEmpty && !_isCurrentVoiceRoom(payloadRoomId)) {
return;
}
if (_isCurrentUserRoomRedPacketPayload(payload)) { if (_isCurrentUserRoomRedPacketPayload(payload)) {
return; return;
} }
final dialogData = RoomRedPacketUiData.fromJson( final dialogData = RoomRedPacketUiData.fromJson(payloadMap);
_broadcastPayloadMap(payload),
);
if (!dialogData.hasPacketId || if (!dialogData.hasPacketId ||
!dialogData.isOpenable || !dialogData.isOpenable ||
RoomRedPacketOpenDialog.isClaimedLocally(dialogData.packetId)) { RoomRedPacketOpenDialog.isClaimedLocally(dialogData.packetId)) {
@ -1811,11 +1892,9 @@ class RealTimeMessagingManager extends ChangeNotifier {
: '', : '',
priority: 1000, priority: 1000,
); );
if (msg.roomId == final packetRoomId = _payloadText(fData["roomId"]);
Provider.of<RealTimeCommunicationManager>( if (packetRoomId.isNotEmpty &&
context!, packetRoomId == _currentVoiceRoomId()) {
listen: false,
).currenRoom?.roomProfile?.roomProfile?.id) {
final rtcProvider = Provider.of<RealTimeCommunicationManager>( final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context!, context!,
listen: false, listen: false,
@ -1894,6 +1973,11 @@ class RealTimeMessagingManager extends ChangeNotifier {
number: fData["totalCount"], number: fData["totalCount"],
priority: 1000, priority: 1000,
); );
final packetRoomId = _payloadText(fData["roomId"]);
if (packetRoomId.isNotEmpty &&
packetRoomId != _currentVoiceRoomId()) {
return;
}
Provider.of<RealTimeCommunicationManager>( Provider.of<RealTimeCommunicationManager>(
context!, context!,
listen: false, listen: false,

View File

@ -13,8 +13,8 @@ class SCEntryPopupRes {
final map = json is Map<String, dynamic> ? json : const <String, dynamic>{}; final map = json is Map<String, dynamic> ? json : const <String, dynamic>{};
final queue = final queue =
(map['queue'] as List<dynamic>? ?? const <dynamic>[]) (map['queue'] as List<dynamic>? ?? const <dynamic>[])
.map((item) => item.toString()) .map((item) => item.toString().trim())
.where((item) => item.trim().isNotEmpty) .where((item) => item.isNotEmpty)
.toList(); .toList();
final popups = <String, SCEntryPopupItem>{}; final popups = <String, SCEntryPopupItem>{};
for (final entry in map.entries) { for (final entry in map.entries) {

View File

@ -560,6 +560,9 @@ class OverlayManager {
// RTM IM // RTM IM
return message.type == 4; return message.type == 4;
} }
if (message.type == 4 && rtcProvider.shouldShowRoomVisualEffects) {
return true;
}
if (SCGlobalConfig.isFloatingBroadcastRoomOnly) { if (SCGlobalConfig.isFloatingBroadcastRoomOnly) {
return false; return false;
} }

View File

@ -38,34 +38,57 @@ class SCEntryPopupCoordinator {
BuildContext context, { BuildContext context, {
SCEntryPopupRootTabSelector? onSelectRootTab, SCEntryPopupRootTabSelector? onSelectRootTab,
}) async { }) 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; return;
} }
final now = DateTime.now(); final now = DateTime.now();
final lastRequestAt = _lastRequestAt; final lastRequestAt = _lastRequestAt;
if (lastRequestAt != null && if (lastRequestAt != null &&
now.difference(lastRequestAt) < _requestMinGap) { now.difference(lastRequestAt) < _requestMinGap) {
_log('skip reason=throttled');
return; return;
} }
_isRunning = true; _isRunning = true;
_lastRequestAt = now; _lastRequestAt = now;
try { try {
_log('request start');
final response = await SCConfigRepositoryImp().entryPopup(); final response = await SCConfigRepositoryImp().entryPopup();
_log(
'response requestId=${response.requestId} '
'queue=${response.queue} popups=${response.popups.keys.toList()}',
);
final popup = _pickDisplayablePopup(response); final popup = _pickDisplayablePopup(response);
if (popup == null) { if (popup == null) {
_log('skip reason=no-displayable-popup');
return; return;
} }
final imageUrl = normalizeImageResourceUrl(popup.image);
final imageReady = await warmImageResource( final imageReady = await warmImageResource(
popup.image, imageUrl,
logicalWidth: 326, logicalWidth: 326,
timeout: const Duration(seconds: 8), timeout: const Duration(seconds: 8),
); );
if (!imageReady || !context.mounted) { if (!imageReady || !context.mounted) {
_log(
'skip reason=image-not-ready key=${popup.key} '
'image=${_clip(imageUrl)} mounted=${context.mounted}',
);
return; return;
} }
_log('show key=${popup.key} popupId=${popup.popupId}');
await SCEntryPopupDialog.show( await SCEntryPopupDialog.show(
context, context,
popup: popup, popup: popup,
imageUrl: imageUrl,
onDisplayed: () { onDisplayed: () {
unawaited(_markShown(popup)); unawaited(_markShown(popup));
}, },
@ -81,29 +104,64 @@ class SCEntryPopupCoordinator {
} }
static SCEntryPopupItem? _pickDisplayablePopup(SCEntryPopupRes response) { static SCEntryPopupItem? _pickDisplayablePopup(SCEntryPopupRes response) {
final queuedKeys = <String>{};
for (final popupKey in response.queue) { for (final popupKey in response.queue) {
queuedKeys.add(popupKey);
final popup = response.popups[popupKey]; final popup = response.popups[popupKey];
if (popup == null || !_canShow(popup)) { if (popup == null) {
_log('skip candidate key=$popupKey reason=missing-popup');
continue; 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 popup;
} }
return null; return null;
} }
static bool _canShow(SCEntryPopupItem popup) { static String? _cannotShowReason(SCEntryPopupItem popup) {
if (!popup.value || popup.image.trim().isEmpty) { if (!popup.value) {
return false; return 'disabled${popup.reason.isEmpty ? '' : '(${popup.reason})'}';
}
if (popup.image.trim().isEmpty) {
return 'image-empty';
} }
if (popup.limit <= 0) { 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) { if (lastShownAt <= 0) {
return true; return null;
} }
final nowMs = DateTime.now().millisecondsSinceEpoch; 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<bool> _markShown(SCEntryPopupItem popup) { static Future<bool> _markShown(SCEntryPopupItem popup) {
@ -412,4 +470,15 @@ class SCEntryPopupCoordinator {
} }
return ''; 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)}...';
}
} }

View File

@ -38,6 +38,35 @@ Map<String, String>? buildNetworkImageHeaders(String url) {
return headers; 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) { int? _resolveImageProviderCacheDimension(double? logicalSize) {
if (logicalSize == null || !logicalSize.isFinite || logicalSize <= 0) { if (logicalSize == null || !logicalSize.isFinite || logicalSize <= 0) {
return null; return null;
@ -54,18 +83,19 @@ ImageProvider<Object> buildCachedImageProvider(
double? logicalWidth, double? logicalWidth,
double? logicalHeight, double? logicalHeight,
}) { }) {
final normalizedUrl = normalizeImageResourceUrl(url);
late final ImageProvider<Object> provider; late final ImageProvider<Object> provider;
if (url.startsWith("http")) { if (normalizedUrl.startsWith("http")) {
provider = ExtendedNetworkImageProvider( provider = ExtendedNetworkImageProvider(
url, normalizedUrl,
headers: buildNetworkImageHeaders(url), headers: buildNetworkImageHeaders(normalizedUrl),
cache: true, cache: true,
cacheMaxAge: const Duration(days: 7), cacheMaxAge: const Duration(days: 7),
); );
} else if (url.startsWith('/')) { } else if (normalizedUrl.startsWith('/')) {
provider = FileImage(File(url)); provider = FileImage(File(normalizedUrl));
} else { } else {
provider = AssetImage(url); provider = AssetImage(normalizedUrl);
} }
return ResizeImage.resizeIfNeeded( return ResizeImage.resizeIfNeeded(
_resolveImageProviderCacheDimension(logicalWidth), _resolveImageProviderCacheDimension(logicalWidth),

View File

@ -11,6 +11,7 @@ class SCEntryPopupDialog extends StatefulWidget {
super.key, super.key,
required this.popup, required this.popup,
required this.onTap, required this.onTap,
this.imageUrl,
this.onDisplayed, this.onDisplayed,
}); });
@ -18,12 +19,14 @@ class SCEntryPopupDialog extends StatefulWidget {
final SCEntryPopupItem popup; final SCEntryPopupItem popup;
final VoidCallback onTap; final VoidCallback onTap;
final String? imageUrl;
final VoidCallback? onDisplayed; final VoidCallback? onDisplayed;
static Future<void> show( static Future<void> show(
BuildContext context, { BuildContext context, {
required SCEntryPopupItem popup, required SCEntryPopupItem popup,
required VoidCallback onTap, required VoidCallback onTap,
String? imageUrl,
VoidCallback? onDisplayed, VoidCallback? onDisplayed,
}) async { }) async {
SmartDialog.dismiss(tag: dialogTag); SmartDialog.dismiss(tag: dialogTag);
@ -37,6 +40,7 @@ class SCEntryPopupDialog extends StatefulWidget {
(_) => SCEntryPopupDialog( (_) => SCEntryPopupDialog(
popup: popup, popup: popup,
onTap: onTap, onTap: onTap,
imageUrl: imageUrl,
onDisplayed: onDisplayed, onDisplayed: onDisplayed,
), ),
); );
@ -65,7 +69,7 @@ class _SCEntryPopupDialogState extends State<SCEntryPopupDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width; final screenWidth = MediaQuery.sizeOf(context).width;
final dialogWidth = math.min(screenWidth - 42.w, 326.w); 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( return Material(
color: Colors.transparent, color: Colors.transparent,

View File

@ -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/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:provider/provider.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/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/audio/rtm_manager.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart'; import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart'; import 'package:yumi/services/music/room_music_manager.dart';
@ -86,10 +86,7 @@ class _ExitMinRoomPageState extends State<ExitMinRoomPage> {
unawaited(context.read<RoomMusicManager>().stopForRoomExit()); unawaited(context.read<RoomMusicManager>().stopForRoomExit());
giftAnimationManager.cleanupAnimationResources(); giftAnimationManager.cleanupAnimationResources();
SCFloatIchart().show(); SCFloatIchart().show();
SCNavigatorUtils.popUntil( VoiceRoomRoute.popVoiceRoomToPrevious(context);
context,
ModalRoute.withName(SCRoutes.home),
);
}, },
), ),
SCDebounceWidget( SCDebounceWidget(

View File

@ -313,13 +313,11 @@ class _FloatingRoomRedenvelopeScreenWidgetState
} }
return; return;
} }
SCRoomUtils.goRoom( if ((currentRoomId ?? '').trim().isEmpty) {
roomId, rtcProvider.joinVoiceRoomSession(currentContext, roomId);
currentContext, return;
needOpenRedenvelope: true, }
redPackId: widget.message.toUserId ?? "", SCRoomUtils.goRoom(roomId, currentContext, fromFloting: true);
fromFloting: true,
);
} }
String _senderNameForFloating() { String _senderNameForFloating() {

View File

@ -247,8 +247,8 @@ class _RoomRedPacketHistoryTile extends StatelessWidget {
switch (status) { switch (status) {
case _RoomRedPacketHistoryStatus.sent: case _RoomRedPacketHistoryStatus.sent:
return localizations.roomRedPacketHistoryStatusSent; return localizations.roomRedPacketHistoryStatusSent;
case _RoomRedPacketHistoryStatus.pending: case _RoomRedPacketHistoryStatus.finished:
return localizations.roomRedPacketHistoryStatusPending; return localizations.roomRedPacketHistoryStatusFinished;
case _RoomRedPacketHistoryStatus.returned: case _RoomRedPacketHistoryStatus.returned:
return localizations.roomRedPacketHistoryStatusReturned; return localizations.roomRedPacketHistoryStatusReturned;
} }
@ -258,8 +258,8 @@ class _RoomRedPacketHistoryTile extends StatelessWidget {
switch (status) { switch (status) {
case _RoomRedPacketHistoryStatus.sent: case _RoomRedPacketHistoryStatus.sent:
return const Color(0xFFFF4A3C); return const Color(0xFFFF4A3C);
case _RoomRedPacketHistoryStatus.pending: case _RoomRedPacketHistoryStatus.finished:
return const Color(0xFFFFC857); return const Color(0xFFFF4A3C);
case _RoomRedPacketHistoryStatus.returned: case _RoomRedPacketHistoryStatus.returned:
return const Color(0xFF66D7A3); return const Color(0xFF66D7A3);
} }
@ -365,11 +365,19 @@ class _RoomRedPacketHistoryItem {
SCRoomRedPacketSentRecordRes record, SCRoomRedPacketSentRecordRes record,
) { ) {
final status = record.status.toLowerCase(); 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; return _RoomRedPacketHistoryStatus.returned;
} }
if (status.contains('pending') || status.contains('active')) { final isFinishedByStatus =
return _RoomRedPacketHistoryStatus.pending; 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; return _RoomRedPacketHistoryStatus.sent;
} }
@ -399,4 +407,4 @@ String _formatHistoryTime(String raw) {
String _bidiSafeLtr(String value) => '\u200E$value\u200E'; String _bidiSafeLtr(String value) => '\u200E$value\u200E';
enum _RoomRedPacketHistoryStatus { sent, pending, returned } enum _RoomRedPacketHistoryStatus { sent, finished, returned }

View File

@ -24,6 +24,7 @@ class RoomRedPacketOpenDialog extends StatefulWidget {
static const String dialogTag = 'showRoomRedPacketOpenDialog'; static const String dialogTag = 'showRoomRedPacketOpenDialog';
static final Map<String, num> _claimedAmountByPacketId = {}; static final Map<String, num> _claimedAmountByPacketId = {};
static final Set<String> _unavailablePacketIds = {};
static final ValueNotifier<int> claimedStateNotifier = ValueNotifier<int>(0); static final ValueNotifier<int> claimedStateNotifier = ValueNotifier<int>(0);
final RoomRedPacketUiData data; final RoomRedPacketUiData data;
@ -85,6 +86,10 @@ class RoomRedPacketOpenDialog extends StatefulWidget {
return _claimedAmountByPacketId.containsKey(_normalizePacketId(packetId)); return _claimedAmountByPacketId.containsKey(_normalizePacketId(packetId));
} }
static bool isUnavailableLocally(String packetId) {
return _unavailablePacketIds.contains(_normalizePacketId(packetId));
}
static void rememberClaimed(String packetId, num amount) { static void rememberClaimed(String packetId, num amount) {
final normalizedPacketId = _normalizePacketId(packetId); final normalizedPacketId = _normalizePacketId(packetId);
if (normalizedPacketId.isEmpty) { if (normalizedPacketId.isEmpty) {
@ -95,11 +100,22 @@ class RoomRedPacketOpenDialog extends StatefulWidget {
claimedStateNotifier.value++; 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() { static void clearClaimedState() {
if (_claimedAmountByPacketId.isEmpty) { if (_claimedAmountByPacketId.isEmpty && _unavailablePacketIds.isEmpty) {
return; return;
} }
_claimedAmountByPacketId.clear(); _claimedAmountByPacketId.clear();
_unavailablePacketIds.clear();
claimedStateNotifier.value++; claimedStateNotifier.value++;
} }
@ -154,12 +170,7 @@ class RoomRedPacketOpenDialog extends StatefulWidget {
static String _normalizePacketId(String packetId) => packetId.trim(); static String _normalizePacketId(String packetId) => packetId.trim();
static bool _shouldIncludeRoomPacket(RoomRedPacketUiData data) { static bool _shouldIncludeRoomPacket(RoomRedPacketUiData data) {
final normalizedStatus = data.statusDesc.toLowerCase(); return !data.grabbed && !data.isExpired && !data.isSoldOut;
final explicitSoldOut =
data.status == 2 ||
normalizedStatus.contains('finish') ||
normalizedStatus.contains('sold');
return !data.grabbed && !data.isExpired && !explicitSoldOut;
} }
static void _addPacketIfAbsent( static void _addPacketIfAbsent(
@ -709,6 +720,7 @@ class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> {
} }
final amount = result.amount ?? widget.data.mockClaimAmount; final amount = result.amount ?? widget.data.mockClaimAmount;
RoomRedPacketOpenDialog.rememberClaimed(widget.data.packetId, amount); RoomRedPacketOpenDialog.rememberClaimed(widget.data.packetId, amount);
_sendClaimSuccessRoomMessage(amount);
setState(() { setState(() {
_claimAmount = amount; _claimAmount = amount;
_state = _RoomRedPacketOpenState.opened; _state = _RoomRedPacketOpenState.opened;
@ -719,6 +731,10 @@ class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> {
return; return;
} }
final code = roomRedPacketErrorCode(error); final code = roomRedPacketErrorCode(error);
if (code == 'voice_room_red_packet_expired' ||
code == 'voice_room_red_packet_finished') {
RoomRedPacketOpenDialog.rememberUnavailable(widget.data.packetId);
}
setState(() { setState(() {
if (code == 'voice_room_red_packet_not_started') { if (code == 'voice_room_red_packet_not_started') {
_state = _RoomRedPacketOpenState.waiting; _state = _RoomRedPacketOpenState.waiting;
@ -755,6 +771,25 @@ class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> {
} catch (_) {} } 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<RtcProvider>(
context,
listen: false,
).sendRoomTextMessage(message);
}
bool get _isLocalPreviewPacket { bool get _isLocalPreviewPacket {
final packetId = widget.data.packetId.trim(); final packetId = widget.data.packetId.trim();
return packetId.isEmpty || return packetId.isEmpty ||

View File

@ -82,6 +82,10 @@ class RoomRedPacketPendingCache extends ChangeNotifier {
.toList(growable: false); .toList(growable: false);
} }
void pruneExpired() {
_pruneExpired();
}
void removePacket(String packetId) { void removePacket(String packetId) {
final normalizedPacketId = packetId.trim(); final normalizedPacketId = packetId.trim();
if (normalizedPacketId.isEmpty) { if (normalizedPacketId.isEmpty) {

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -51,9 +53,112 @@ class _RoomPlayWidgetState extends State<RoomPlayWidget> {
} }
} }
class _RoomRedPacketFloatingEntry extends StatelessWidget { class _RoomRedPacketFloatingEntry extends StatefulWidget {
const _RoomRedPacketFloatingEntry(); 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<String> _verifiedKnownPacketIds = const <String>{};
Set<String> _verifiedVisiblePacketIds = const <String>{};
@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<RtcProvider>(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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
@ -67,6 +172,10 @@ class _RoomRedPacketFloatingEntry extends StatelessWidget {
final snapshot = _RoomRedPacketEntrySnapshot.fromProviders( final snapshot = _RoomRedPacketEntrySnapshot.fromProviders(
rtcProvider: rtcProvider, rtcProvider: rtcProvider,
rtmProvider: rtmProvider, rtmProvider: rtmProvider,
verifiedRoomId: _verifiedRoomId,
verifiedRoomRefreshCount: _verifiedRoomRefreshCount,
verifiedKnownPacketIds: _verifiedKnownPacketIds,
verifiedVisiblePacketIds: _verifiedVisiblePacketIds,
); );
if (snapshot.packets.isEmpty) { if (snapshot.packets.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -76,6 +185,10 @@ class _RoomRedPacketFloatingEntry extends StatelessWidget {
final freshSnapshot = _RoomRedPacketEntrySnapshot.fromProviders( final freshSnapshot = _RoomRedPacketEntrySnapshot.fromProviders(
rtcProvider: Provider.of<RtcProvider>(context, listen: false), rtcProvider: Provider.of<RtcProvider>(context, listen: false),
rtmProvider: Provider.of<RtmProvider>(context, listen: false), rtmProvider: Provider.of<RtmProvider>(context, listen: false),
verifiedRoomId: _verifiedRoomId,
verifiedRoomRefreshCount: _verifiedRoomRefreshCount,
verifiedKnownPacketIds: _verifiedKnownPacketIds,
verifiedVisiblePacketIds: _verifiedVisiblePacketIds,
); );
if (freshSnapshot.packets.isEmpty) { if (freshSnapshot.packets.isEmpty) {
return; return;
@ -145,19 +258,41 @@ class _RoomRedPacketEntrySnapshot {
factory _RoomRedPacketEntrySnapshot.fromProviders({ factory _RoomRedPacketEntrySnapshot.fromProviders({
required RtcProvider rtcProvider, required RtcProvider rtcProvider,
required RtmProvider rtmProvider, required RtmProvider rtmProvider,
required String verifiedRoomId,
required int verifiedRoomRefreshCount,
required Set<String> verifiedKnownPacketIds,
required Set<String> verifiedVisiblePacketIds,
}) { }) {
final packets = <RoomRedPacketUiData>[]; final packets = <RoomRedPacketUiData>[];
final hiddenPacketIds = <String>{};
final currentRoomId = final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
final shouldTrustVerifiedAbsence =
currentRoomId.isNotEmpty &&
verifiedRoomId == currentRoomId &&
verifiedRoomRefreshCount >= 2;
for (final packet in rtcProvider.redPacketList.map( for (final packet in rtcProvider.redPacketList.map(
RoomRedPacketUiData.fromListRes, RoomRedPacketUiData.fromListRes,
)) { )) {
_addVisiblePacket(packets, packet); _addVisiblePacket(
packets,
packet,
hiddenPacketIds: hiddenPacketIds,
authoritative: true,
);
} }
for (final packet in RoomRedPacketPendingCache.instance.packetsForRoom( for (final packet in RoomRedPacketPendingCache.instance.packetsForRoom(
currentRoomId, currentRoomId,
)) { )) {
_addVisiblePacket(packets, packet); _addVisiblePacket(
packets,
packet,
hiddenPacketIds: hiddenPacketIds,
verifiedKnownPacketIds:
shouldTrustVerifiedAbsence ? verifiedKnownPacketIds : null,
verifiedVisiblePacketIds:
shouldTrustVerifiedAbsence ? verifiedVisiblePacketIds : null,
);
} }
for (final msg in rtmProvider.roomChatMsgList) { for (final msg in rtmProvider.roomChatMsgList) {
if (msg.type != SCRoomMsgType.roomRedPacket) { if (msg.type != SCRoomMsgType.roomRedPacket) {
@ -169,6 +304,11 @@ class _RoomRedPacketEntrySnapshot {
payload: msg.msg, payload: msg.msg,
fallbackUser: msg.user, fallbackUser: msg.user,
), ),
hiddenPacketIds: hiddenPacketIds,
verifiedKnownPacketIds:
shouldTrustVerifiedAbsence ? verifiedKnownPacketIds : null,
verifiedVisiblePacketIds:
shouldTrustVerifiedAbsence ? verifiedVisiblePacketIds : null,
); );
} }
final signature = packets final signature = packets
@ -197,13 +337,40 @@ class _RoomRedPacketEntrySnapshot {
void _addVisiblePacket( void _addVisiblePacket(
List<RoomRedPacketUiData> packets, List<RoomRedPacketUiData> packets,
RoomRedPacketUiData packet, RoomRedPacketUiData packet, {
) { Set<String>? hiddenPacketIds,
Set<String>? verifiedKnownPacketIds,
Set<String>? verifiedVisiblePacketIds,
bool authoritative = false,
}) {
final normalizedPacket = RoomRedPacketOpenDialog.withLocalClaimState(packet); 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; return;
} }
final packetId = normalizedPacket.packetId.trim();
if (packetId.isNotEmpty && if (packetId.isNotEmpty &&
packets.any((item) => item.packetId.trim() == packetId)) { packets.any((item) => item.packetId.trim() == packetId)) {
return; return;
@ -212,10 +379,5 @@ void _addVisiblePacket(
} }
bool _shouldShowRoomRedPacketEntry(RoomRedPacketUiData item) { bool _shouldShowRoomRedPacketEntry(RoomRedPacketUiData item) {
final normalizedStatus = item.statusDesc.toLowerCase(); return !item.grabbed && !item.isExpired && !item.isSoldOut;
final explicitSoldOut =
item.status == 2 ||
normalizedStatus.contains('finish') ||
normalizedStatus.contains('sold');
return !item.grabbed && !item.isExpired && !explicitSoldOut;
} }

View File

@ -550,6 +550,9 @@
- 已补齐红包领取记录页的发包摘要:从发红包记录页点击进入后,顶部固定展示该红包图标、红包数量、发送时间、发出金额和退款金额;即使领取列表为空仍展示红包金额,空态只作为领取人列表区域提示。 - 已补齐红包领取记录页的发包摘要:从发红包记录页点击进入后,顶部固定展示该红包图标、红包数量、发送时间、发出金额和退款金额;即使领取列表为空仍展示红包金额,空态只作为领取人列表区域提示。
- 已新增红包发送余额不足充值引导:发送接口返回 `voice_room_red_packet_insufficient_balance` 时关闭红包发送面板,改为弹出约半屏高度的底部充值弹窗,展示当前金币余额和充值按钮;点击充值复用 `me/wallet``WalletRoute.recharge` 进入充值页,返回后刷新用户余额。 - 已新增红包发送余额不足充值引导:发送接口返回 `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 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。 - 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji``monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。