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": "سجل إرسال حزم الحظ",
"roomRedPacketHistoryItemTitle": "حزمة حظ ({1} مظروفاً)",
"roomRedPacketHistoryStatusSent": "تم الإرسال",
"roomRedPacketHistoryStatusFinished": "تم استلامها بالكامل",
"roomRedPacketHistoryStatusPending": "قيد الانتظار",
"roomRedPacketHistoryStatusReturned": "تم الإرجاع",
"roomRedPacketHistoryRefund": "المسترد: {1}",

View File

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

View File

@ -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}",

View File

@ -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}",

View File

@ -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

View File

@ -655,6 +655,9 @@ class SCAppLocalizations {
String get roomRedPacketHistoryStatusSent =>
translate('roomRedPacketHistoryStatusSent');
String get roomRedPacketHistoryStatusFinished =>
translate('roomRedPacketHistoryStatusFinished');
String get 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>(
BuildContext context, {
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 _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<String> _claimedTaskIds = {};
@ -33,6 +34,7 @@ class _TaskPageState extends State<TaskPage>
bool _loadingTasks = false;
String? _toastMessage;
Timer? _toastTimer;
Timer? _cacheRefreshTimer;
PageRoute<dynamic>? _routeObserverRoute;
@override
@ -47,6 +49,7 @@ class _TaskPageState extends State<TaskPage>
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
_toastTimer?.cancel();
_cacheRefreshTimer?.cancel();
super.dispose();
}
@ -68,6 +71,7 @@ class _TaskPageState extends State<TaskPage>
if (state == AppLifecycleState.resumed) {
debugPrint('[TaskCenter][Page] refresh on app resumed');
unawaited(_loadTasks());
_scheduleTaskStatusCacheRefresh('app resumed');
}
}
@ -75,6 +79,7 @@ class _TaskPageState extends State<TaskPage>
void didPopNext() {
debugPrint('[TaskCenter][Page] refresh on route return');
unawaited(_loadTasks());
_scheduleTaskStatusCacheRefresh('route return');
}
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
Widget build(BuildContext context) {
final dailyTasks = _sectionTasks(isNewcomer: false);
@ -131,7 +157,7 @@ class _TaskPageState extends State<TaskPage>
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<TaskPage>
if (opened && mounted) {
debugPrint('[TaskCenter][Page] refresh after jump return');
unawaited(_loadTasks());
_scheduleTaskStatusCacheRefresh('jump 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: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<void>.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<void>.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();

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) {
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<RealTimeCommunicationManager>(
return Provider.of<RealTimeCommunicationManager>(
currentContext,
listen: false,
).currenRoom?.roomProfile?.roomProfile?.id?.trim() ??
'';
return currentRoomId.isNotEmpty && currentRoomId == roomId.trim();
}
Map<String, dynamic> _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<RealTimeCommunicationManager>(
context!,
listen: false,
).currenRoom?.roomProfile?.roomProfile?.id) {
final packetRoomId = _payloadText(fData["roomId"]);
if (packetRoomId.isNotEmpty &&
packetRoomId == _currentVoiceRoomId()) {
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
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<RealTimeCommunicationManager>(
context!,
listen: false,

View File

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

View File

@ -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;
}

View File

@ -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 = <String>{};
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<bool> _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)}...';
}
}

View File

@ -38,6 +38,35 @@ Map<String, String>? 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<Object> buildCachedImageProvider(
double? logicalWidth,
double? logicalHeight,
}) {
final normalizedUrl = normalizeImageResourceUrl(url);
late final ImageProvider<Object> 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),

View File

@ -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<void> 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<SCEntryPopupDialog> {
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,

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/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<ExitMinRoomPage> {
unawaited(context.read<RoomMusicManager>().stopForRoomExit());
giftAnimationManager.cleanupAnimationResources();
SCFloatIchart().show();
SCNavigatorUtils.popUntil(
context,
ModalRoute.withName(SCRoutes.home),
);
VoiceRoomRoute.popVoiceRoomToPrevious(context);
},
),
SCDebounceWidget(

View File

@ -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() {

View File

@ -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 }

View File

@ -24,6 +24,7 @@ class RoomRedPacketOpenDialog extends StatefulWidget {
static const String dialogTag = 'showRoomRedPacketOpenDialog';
static final Map<String, num> _claimedAmountByPacketId = {};
static final Set<String> _unavailablePacketIds = {};
static final ValueNotifier<int> claimedStateNotifier = ValueNotifier<int>(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<RtcProvider>(
context,
listen: false,
).sendRoomTextMessage(message);
}
bool get _isLocalPreviewPacket {
final packetId = widget.data.packetId.trim();
return packetId.isEmpty ||

View File

@ -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) {

View File

@ -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<RoomPlayWidget> {
}
}
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<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
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<RtcProvider>(context, listen: false),
rtmProvider: Provider.of<RtmProvider>(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<String> verifiedKnownPacketIds,
required Set<String> verifiedVisiblePacketIds,
}) {
final packets = <RoomRedPacketUiData>[];
final hiddenPacketIds = <String>{};
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<RoomRedPacketUiData> packets,
RoomRedPacketUiData packet,
) {
RoomRedPacketUiData packet, {
Set<String>? hiddenPacketIds,
Set<String>? verifiedKnownPacketIds,
Set<String>? 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;
}

View File

@ -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 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。