This commit is contained in:
roxy 2026-05-08 21:30:06 +08:00
parent 85a1b01347
commit 35c05a9123
21 changed files with 1111 additions and 175 deletions

View File

@ -577,6 +577,8 @@
"resetsDailyAtMidnight": "يُعاد التعيين يوميًا عند منتصف الليل",
"limitedToOneTime": "لمرة واحدة فقط",
"useMicrophoneForOneMin": "استخدم الميكروفون لمدة دقيقة واحدة",
"taskRequirement": "المتطلبات",
"taskProgress": "التقدم",
"rewardClaimedSuccessfully": "تم استلام المكافأة بنجاح",
"rewardClaimFailed": "فشل استلام المكافأة",
"nickName": "الاسم المستعار",

View File

@ -513,6 +513,8 @@
"resetsDailyAtMidnight": "প্রতিদিন মধ্যরাতে রিসেট হয়",
"limitedToOneTime": "শুধু একবারের জন্য",
"useMicrophoneForOneMin": "১ মিনিট মাইক্রোফোন ব্যবহার করুন",
"taskRequirement": "প্রয়োজন",
"taskProgress": "অগ্রগতি",
"rewardClaimedSuccessfully": "পুরস্কার সফলভাবে গ্রহণ করা হয়েছে",
"rewardClaimFailed": "পুরস্কার গ্রহণ ব্যর্থ হয়েছে",
"enterRoomConfirmTips": "আপনি কি রুমে যেতে চান?",

View File

@ -483,6 +483,8 @@
"resetsDailyAtMidnight": "Resets daily at midnight",
"limitedToOneTime": "Limited to one time",
"useMicrophoneForOneMin": "Use the microphone for 1 min",
"taskRequirement": "Requirement",
"taskProgress": "Progress",
"rewardClaimedSuccessfully": "Reward claimed successfully",
"rewardClaimFailed": "Reward claim failed",
"enterRoomConfirmTips": "Are you sure you want to enter the room?",

View File

@ -482,6 +482,8 @@
"resetsDailyAtMidnight": "Her gün gece yarısı sıfırlanır",
"limitedToOneTime": "Yalnızca bir kez",
"useMicrophoneForOneMin": "Mikrofonu 1 dakika kullan",
"taskRequirement": "Gereksinim",
"taskProgress": "İlerleme",
"rewardClaimedSuccessfully": "Ödül başarıyla alındı",
"rewardClaimFailed": "Ödül alınamadı",
"enterRoomConfirmTips": "Odaya girmek istediğinizden emin misiniz?",

View File

@ -906,6 +906,10 @@ class SCAppLocalizations {
String get useMicrophoneForOneMin => translate('useMicrophoneForOneMin');
String get taskRequirement => translate('taskRequirement');
String get taskProgress => translate('taskProgress');
String get rewardClaimedSuccessfully =>
translate('rewardClaimedSuccessfully');

View File

@ -54,22 +54,34 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_keybord_util.dart';
import 'package:yumi/shared/tools/sc_message_notifier.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/modules/index/main_route.dart';
import 'package:yumi/ui_kit/widgets/sc_nine_patch_image.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart';
import '../../shared/business_logic/models/res/sc_user_red_packet_send_res.dart';
const EdgeInsets _vipBubbleCenterSliceRatio = EdgeInsets.fromLTRB(
0.34,
0.34,
0.34,
0.34,
);
const EdgeInsets _vipBubbleSourceEdgeInset = EdgeInsets.all(2);
String _resolveVipChatBubbleImageUrl(SCVipResourceRes? resource) {
return _firstNonBlankChatBubbleUrl([
final candidates = <String?>[
resource?.sourceResourceUrl,
resource?.previewUrl,
resource?.coverUrl,
resource?.cover,
_staticChatBubbleUrl(resource?.sourceResourceUrl),
];
return _firstNonBlankChatBubbleUrl([
...candidates.where((value) => SCNetworkSvgaWidget.isSvga(value)),
...candidates,
]);
}
@ -83,14 +95,6 @@ String _firstNonBlankChatBubbleUrl(Iterable<String?> values) {
return "";
}
String? _staticChatBubbleUrl(String? value) {
final text = value?.trim();
if (text == null || text.isEmpty) {
return null;
}
return SCNetworkSvgaWidget.isSvga(text) ? null : text;
}
class SCMessageChatPage extends StatefulWidget {
final V2TimConversation? conversation;
final bool shrinkWrap;
@ -1698,6 +1702,8 @@ class _MessageItem extends StatelessWidget {
width: width,
height: height,
fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: SizedBox(width: width, height: height),
)
: _buildStaticVipBubbleImage(
@ -1721,6 +1727,8 @@ class _MessageItem extends StatelessWidget {
width: width,
height: height,
fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: fallback,
);
}
@ -1743,22 +1751,13 @@ class _MessageItem extends StatelessWidget {
return fallback ?? SizedBox(width: width, height: height);
}
return Image(
image: buildCachedImageProvider(
imageUrl,
logicalWidth: width,
logicalHeight: height,
),
return SCNinePatchImage(
url: imageUrl,
width: width,
height: height,
fit: BoxFit.fill,
filterQuality: FilterQuality.low,
errorBuilder: (context, error, stackTrace) {
debugPrint(
'[VIP][ChatBubble] image load failed url=$imageUrl error=$error',
);
return fallback ?? SizedBox(width: width, height: height);
},
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: fallback ?? SizedBox(width: width, height: height),
);
}
@ -1775,7 +1774,7 @@ class _MessageItem extends StatelessWidget {
}
String _resolveChatBubbleImageUrl(PropsResources? resource) {
return _firstNonBlank([
final candidates = <String?>[
resource?.imSendCoverUrl,
resource?.imOpenedUrl,
resource?.imOpenedUrlTwo,
@ -1786,6 +1785,10 @@ class _MessageItem extends StatelessWidget {
resource?.cover,
resource?.expand,
resource?.sourceUrl,
];
return _firstNonBlank([
...candidates.where((value) => SCNetworkSvgaWidget.isSvga(value)),
...candidates,
]);
}

View File

@ -51,6 +51,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
static const Color _profileBg = Color(0xff072121);
static const Color _cardBg = Color(0xff08251E);
static const Color _profileBorder = Color(0xffB2FBCC);
static const int _wallPreviewItemCount = 9;
SCUserIdentityRes? userIdentity;
@ -149,7 +150,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
.giftWall(widget.tageId)
.then((result) {
if (!mounted) return;
giftWallList = result;
giftWallList = result.take(_wallPreviewItemCount).toList();
setState(() {});
})
.catchError((e) {});
@ -644,7 +645,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
),
SizedBox(height: 12.w),
_buildWallGrid(
itemCount: 4,
itemCount: _wallPreviewItemCount,
itemBuilder: (_) => _buildHonorPlaceholderItem(),
),
SizedBox(height: 18.w),
@ -659,7 +660,10 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
),
SizedBox(height: 12.w),
_buildWallGrid(
itemCount: giftWallList.isEmpty ? 4 : giftWallList.length,
itemCount:
giftWallList.isEmpty
? _wallPreviewItemCount
: giftWallList.length,
itemBuilder: (index) {
final gift =
index < giftWallList.length ? giftWallList[index] : null;

View File

@ -5,6 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/main.dart' show routeObserver;
import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
@ -17,7 +18,8 @@ class TaskPage extends StatefulWidget {
State<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
class _TaskPageState extends State<TaskPage>
with WidgetsBindingObserver, RouteAware {
static const String _taskMicAsset = 'sc_images/index/sc_icon_task_mic.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';
@ -28,22 +30,59 @@ class _TaskPageState extends State<TaskPage> {
final Set<String> _claimingTaskIds = {};
List<SCTaskListRes> _tasks = [];
bool _loading = true;
bool _loadingTasks = false;
String? _toastMessage;
Timer? _toastTimer;
PageRoute<dynamic>? _routeObserverRoute;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadTasks();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
_toastTimer?.cancel();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute<dynamic> && _routeObserverRoute != route) {
if (_routeObserverRoute != null) {
routeObserver.unsubscribe(this);
}
_routeObserverRoute = route;
routeObserver.subscribe(this, route);
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
debugPrint('[TaskCenter][Page] refresh on app resumed');
unawaited(_loadTasks());
}
}
@override
void didPopNext() {
debugPrint('[TaskCenter][Page] refresh on route return');
unawaited(_loadTasks());
}
Future<void> _loadTasks() async {
if (_loadingTasks) {
debugPrint('[TaskCenter][Page] load skipped: request in flight');
return;
}
_loadingTasks = true;
debugPrint('[TaskCenter][Page] load start');
try {
final tasks = await _repository.tasks();
@ -69,6 +108,8 @@ class _TaskPageState extends State<TaskPage> {
_tasks = [];
_loading = false;
});
} finally {
_loadingTasks = false;
}
}
@ -255,13 +296,13 @@ class _TaskPageState extends State<TaskPage> {
_TaskUiItem _mapTask(SCTaskListRes task) {
final id = task.taskId ?? '';
final currentValue = _parseNumber(task.completedValue) ?? 0;
final targetValue =
_parseNumber(task.targetValue) ??
_parseNumber(task.conditionValue) ??
1000000;
final parsedTargetValue =
_parseNumber(task.targetValue) ?? _parseNumber(task.conditionValue);
final targetValue = parsedTargetValue ?? 0;
final hasProgress = parsedTargetValue != null && parsedTargetValue > 0;
final claimed =
_claimedTaskIds.contains(id) || (task.isRewardCollected ?? 0) == 1;
final completedByValue = targetValue > 0 && currentValue >= targetValue;
final completedByValue = hasProgress && currentValue >= targetValue;
final completedByStatus = (task.taskStatus ?? 0) == 1;
final status =
claimed
@ -275,16 +316,27 @@ class _TaskPageState extends State<TaskPage> {
title:
_nonEmpty(task.taskName) ??
SCAppLocalizations.of(context)!.useMicrophoneForOneMin,
progressText:
_nonEmpty(task.taskDesc) ??
'${_formatPlainNumber(currentValue)}/${_formatPlainNumber(targetValue)}',
rewardText: _formatCompactNumber(task.quantity ?? 1100),
progressText: _taskProgressText(
task,
currentValue: currentValue,
targetValue: targetValue,
hasProgress: hasProgress,
),
requirementText: _taskRequirementText(
task,
targetValue: targetValue,
hasProgress: hasProgress,
),
rewardText: _formatPlainNumber(task.quantity ?? 1100),
status: status,
iconAsset: _iconForTask(task),
iconUrl: _nonEmpty(task.taskIcon) ?? _nonEmpty(task.cover),
jumpPage: _nonEmpty(task.jumpPage),
jumpType: _nonEmpty(task.jumpType),
conditionType: _nonEmpty(task.conditionType),
roomId: _nonEmpty(task.roomId),
roomTask: _isRoomCompletionTask(task),
gameTask: _isGameCompletionTask(task),
fromApi: id.isNotEmpty,
);
}
@ -292,12 +344,18 @@ class _TaskPageState extends State<TaskPage> {
Future<void> _handleTaskAction(_TaskUiItem task) async {
debugPrint(
'[TaskCenter][Page] action id=${task.id} status=${task.status.name} '
'fromApi=${task.fromApi} jumpType=${task.jumpType} '
'jumpPage=${task.jumpPage} roomId=${task.roomId}',
'fromApi=${task.fromApi} conditionType=${task.conditionType} '
'jumpType=${task.jumpType} jumpPage=${task.jumpPage} '
'roomId=${task.roomId} roomTask=${task.roomTask} '
'gameTask=${task.gameTask}',
);
if (task.status == _TaskActionStatus.claimed) return;
if (task.status == _TaskActionStatus.go) {
await _openJumpPage(task);
final opened = await _openJumpPage(task);
if (opened && mounted) {
debugPrint('[TaskCenter][Page] refresh after jump return');
unawaited(_loadTasks());
}
return;
}
@ -324,9 +382,9 @@ class _TaskPageState extends State<TaskPage> {
}
}
Future<void> _openJumpPage(_TaskUiItem task) async {
Future<bool> _openJumpPage(_TaskUiItem task) async {
var target = _taskJumpUrl(task);
if (target.isEmpty) return;
if (target.isEmpty) return false;
var uri = Uri.tryParse(target);
var path = uri?.path ?? '';
var roomId = uri?.queryParameters['roomId']?.trim() ?? '';
@ -336,10 +394,10 @@ class _TaskPageState extends State<TaskPage> {
'target=$target taskId=${task.id}',
);
final fallbackTarget = await _firstRoomJumpUrl(target);
if (!mounted) return;
if (!mounted) return false;
if (fallbackTarget.isEmpty) {
_showToast(SCAppLocalizations.of(context)!.enterRoomFailedRetry);
return;
return false;
}
target = fallbackTarget;
uri = Uri.tryParse(target);
@ -357,6 +415,7 @@ class _TaskPageState extends State<TaskPage> {
jumpType: jumpType,
jumpUrl: target,
);
return true;
}
Future<String> _firstRoomJumpUrl(String target) async {
@ -393,19 +452,7 @@ class _TaskPageState extends State<TaskPage> {
final target = task.jumpPage?.trim() ?? '';
final roomId = task.roomId?.trim() ?? '';
if (target.isEmpty) {
if (_isGameJumpType(task.jumpType)) {
return roomId.isEmpty
? '/game-center'
: '/game-center?roomId=${Uri.encodeComponent(roomId)}';
}
if (_isRoomJumpType(task.jumpType)) {
return roomId.isEmpty
? '/room'
: '/room?roomId=${Uri.encodeComponent(roomId)}';
}
return roomId.isEmpty
? ''
: '/room?roomId=${Uri.encodeComponent(roomId)}';
return _defaultTaskJumpUrl(task, roomId);
}
if (roomId.isEmpty) return target;
@ -415,20 +462,42 @@ class _TaskPageState extends State<TaskPage> {
return _withRoomId(target, roomId);
}
String _defaultTaskJumpUrl(_TaskUiItem task, String roomId) {
if (_isGameJumpType(task.jumpType) || task.gameTask) {
return _routeWithOptionalRoomId('/game-center', roomId);
}
if (_isRoomJumpType(task.jumpType) || task.roomTask) {
return _routeWithOptionalRoomId('/room', roomId);
}
if (_isRechargeTask(task)) {
return '/recharge';
}
return roomId.isEmpty ? '' : _routeWithOptionalRoomId('/room', roomId);
}
String _routeWithOptionalRoomId(String route, String roomId) {
if (roomId.isEmpty) return route;
return '$route?roomId=${Uri.encodeComponent(roomId)}';
}
String _taskJumpType(_TaskUiItem task, String target) {
final uri = Uri.tryParse(target);
final path = uri?.path ?? '';
final jumpType = task.jumpType?.trim();
if (jumpType != null && jumpType.isNotEmpty) {
if (_isGameJumpType(jumpType)) return 'GAME';
if (_isRoomJumpType(jumpType)) return 'APP_ROUTE';
return jumpType;
}
final uri = Uri.tryParse(target);
if (uri?.hasScheme == true) {
return 'H5';
}
if (uri?.path == '/game-center') {
if (path == '/game-center') {
return 'GAME';
}
if (path.startsWith('/')) {
return 'APP_ROUTE';
}
if (jumpType != null && jumpType.isNotEmpty) return jumpType;
return 'APP_ROUTE';
}
@ -436,11 +505,16 @@ class _TaskPageState extends State<TaskPage> {
final normalized = jumpType?.trim().toUpperCase() ?? '';
return normalized == 'ROOM' ||
normalized == 'VOICE_ROOM' ||
normalized == 'VOICECHAT_ROOM';
normalized == 'VOICECHAT_ROOM' ||
normalized == 'ENTER_ROOM' ||
normalized == 'JOIN_ROOM';
}
bool _isGameJumpType(String? jumpType) {
return jumpType?.trim().toUpperCase() == 'GAME';
final normalized = jumpType?.trim().toUpperCase() ?? '';
return normalized == 'GAME' ||
normalized == 'GAME_CENTER' ||
normalized == 'GAME_CONSUME_GOLD';
}
void _showToast(String message) {
@ -461,9 +535,79 @@ class _TaskPageState extends State<TaskPage> {
return _taskDefaultAsset;
}
String _taskProgressText(
SCTaskListRes task, {
required num currentValue,
required num targetValue,
required bool hasProgress,
}) {
if (!hasProgress) return '';
final l10n = SCAppLocalizations.of(context)!;
return '${l10n.taskProgress}: '
'${_formatTaskValue(task, currentValue)}/${_formatTaskValue(task, targetValue)}';
}
String _taskRequirementText(
SCTaskListRes task, {
required num targetValue,
required bool hasProgress,
}) {
final l10n = SCAppLocalizations.of(context)!;
if (hasProgress) {
return '${l10n.taskRequirement}: ${_formatTaskValue(task, targetValue)}';
}
return _nonEmpty(task.taskDesc) ?? '';
}
bool _isGameCompletionTask(SCTaskListRes task) {
final conditionType = task.conditionType?.trim().toUpperCase() ?? '';
if (conditionType.contains('GAME')) return true;
final text = '${task.taskName ?? ''} ${task.taskDesc ?? ''}'.toLowerCase();
return text.contains('game') || text.contains('游戏');
}
bool _isRoomCompletionTask(SCTaskListRes task) {
final conditionType = task.conditionType?.trim().toUpperCase() ?? '';
if (conditionType.contains('RECHARGE')) return false;
if (_isGameCompletionTask(task)) return false;
if (conditionType.contains('MIC') ||
conditionType.contains('VOICE') ||
conditionType.contains('ROOM') ||
conditionType.contains('GIFT') ||
conditionType.contains('CONSUME')) {
return true;
}
final text = '${task.taskName ?? ''} ${task.taskDesc ?? ''}'.toLowerCase();
if (text.contains('recharge') || text.contains('充值')) return false;
if (text.contains('game') || text.contains('游戏')) return false;
return text.contains('mic') ||
text.contains('voice') ||
text.contains('room') ||
text.contains('gift') ||
text.contains('consume') ||
text.contains('spend') ||
text.contains('上麦') ||
text.contains('房间') ||
text.contains('语音') ||
text.contains('礼物') ||
text.contains('消费');
}
bool _isRechargeTask(_TaskUiItem task) {
final text =
'${task.conditionType ?? ''} ${task.title} ${task.jumpType ?? ''}'
.toLowerCase();
return text.contains('recharge') || text.contains('充值');
}
static num? _parseNumber(Object? value) {
if (value is num) return value;
if (value is String) return num.tryParse(value);
if (value is String) {
final text = value.trim();
if (text.isEmpty) return null;
return num.tryParse(text) ?? num.tryParse(text.replaceAll(',', ''));
}
return null;
}
@ -477,20 +621,49 @@ class _TaskPageState extends State<TaskPage> {
return value.toStringAsFixed(1);
}
static String _formatCompactNumber(num value) {
final absValue = value.abs();
if (absValue >= 1000000) {
return '${_trimTrailingZero(value / 1000000)}m';
}
if (absValue >= 1000) {
return '${_trimTrailingZero(value / 1000)}k';
}
String _formatTaskValue(SCTaskListRes task, num value) {
if (_isTimeValueTask(task)) return _formatDurationSeconds(value);
return _formatPlainNumber(value);
}
static String _trimTrailingZero(num value) {
final text = value.toStringAsFixed(1);
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
bool _isTimeValueTask(SCTaskListRes task) {
final conditionType = task.conditionType?.trim().toUpperCase() ?? '';
if (conditionType.contains('GOLD') || conditionType.contains('CONSUME')) {
return false;
}
if (conditionType.contains('SECOND') ||
conditionType.contains('DURATION') ||
RegExp(r'(^|_)TIME($|_)').hasMatch(conditionType)) {
return true;
}
final text = '${task.taskName ?? ''} ${task.taskDesc ?? ''}'.toLowerCase();
return text.contains('second') ||
text.contains('seconds') ||
text.contains('minute') ||
text.contains('minutes') ||
text.contains('hour') ||
text.contains('hours') ||
text.contains(' min') ||
text.contains('') ||
text.contains('分钟') ||
text.contains('小时');
}
static String _formatDurationSeconds(num seconds) {
final absSeconds = seconds.abs();
if (absSeconds >= 3600) {
return '${_formatShortDecimal(seconds / 3600)}h';
}
if (absSeconds >= 60) {
return '${_formatShortDecimal(seconds / 60)}min';
}
return '${_formatShortDecimal(seconds)}s';
}
static String _formatShortDecimal(num value) {
final fixed = value.toStringAsFixed(1);
return fixed.endsWith('.0') ? fixed.substring(0, fixed.length - 2) : fixed;
}
String _taskDebugSample(List<SCTaskListRes> tasks) {
@ -502,6 +675,7 @@ class _TaskPageState extends State<TaskPage> {
'status=${task.taskStatus},collected=${task.isRewardCollected},'
'current=${task.completedValue},target=${task.targetValue},'
'quantity=${task.quantity},name=${task.taskName},'
'condition=${task.conditionType},'
'jumpType=${task.jumpType},jumpPage=${task.jumpPage},'
'roomId=${task.roomId}}';
})
@ -671,7 +845,7 @@ class _TaskRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 65.w,
height: 76.w,
child: Row(
children: [
SizedBox(width: 12.w),
@ -703,18 +877,35 @@ class _TaskRow extends StatelessWidget {
),
),
SizedBox(height: 3.w),
Text(
task.progressText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
if (task.requirementText.isNotEmpty) ...[
Text(
task.requirementText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
),
SizedBox(height: 6.w),
SizedBox(height: 3.w),
],
if (task.progressText.isNotEmpty) ...[
Text(
task.progressText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
SizedBox(height: 5.w),
] else
SizedBox(height: 6.w),
Row(
children: [
Image.asset(
@ -903,26 +1094,34 @@ class _TaskUiItem {
required this.id,
required this.title,
required this.progressText,
required this.requirementText,
required this.rewardText,
required this.status,
required this.iconAsset,
this.iconUrl,
this.jumpPage,
this.jumpType,
this.conditionType,
this.roomId,
this.roomTask = false,
this.gameTask = false,
this.fromApi = false,
});
final String id;
final String title;
final String progressText;
final String requirementText;
final String rewardText;
final _TaskActionStatus status;
final String iconAsset;
final String? iconUrl;
final String? jumpPage;
final String? jumpType;
final String? conditionType;
final String? roomId;
final bool roomTask;
final bool gameTask;
final bool fromApi;
_TaskUiItem copyWith({_TaskActionStatus? status}) {
@ -930,13 +1129,17 @@ class _TaskUiItem {
id: id,
title: title,
progressText: progressText,
requirementText: requirementText,
rewardText: rewardText,
status: status ?? this.status,
iconAsset: iconAsset,
iconUrl: iconUrl,
jumpPage: jumpPage,
jumpType: jumpType,
conditionType: conditionType,
roomId: roomId,
roomTask: roomTask,
gameTask: gameTask,
fromApi: fromApi,
);
}

View File

@ -350,6 +350,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
int? _pendingSelfMicReleaseGuardUntilMs;
ClientRoleType? _lastAppliedClientRole;
bool? _lastAppliedLocalAudioMuted;
bool? _lastAppliedRecordingSignalMuted;
String? _lastAppliedRtcToken;
bool? _lastScheduledVoiceLiveOnMic;
String? _lastScheduledVoiceLiveRoomId;
String? _lastScheduledAnchorRoomId;
@ -1091,6 +1093,20 @@ class RealTimeCommunicationManager extends ChangeNotifier {
.connectionChangedLicenseValidationFailure;
}
MicRes? _currentUserMicSeat() {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
return null;
}
for (final mic in roomWheatMap.values) {
if ((mic.user?.id ?? "").trim() == currentUserId) {
return mic;
}
}
return null;
}
void _syncSelfMicRuntimeState() {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
if ((currentUserId ?? "").isEmpty) {
@ -1098,13 +1114,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
MicRes? currentUserMic;
for (final mic in roomWheatMap.values) {
if (mic.user?.id == currentUserId) {
currentUserMic = mic;
break;
}
}
final currentUserMic = _currentUserMicSeat();
if (currentUserMic == null) {
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
@ -1133,6 +1143,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_applyLocalAudioRuntimeState(
clientRole: ClientRoleType.clientRoleBroadcaster,
muted: (currentUserMic.micMute ?? false) || isMic,
publisherToken: currentUserMic.roomToken,
);
_roomMusicMicRouteChangedListener?.call(true);
}
@ -1211,6 +1222,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void _applyLocalAudioRuntimeState({
required ClientRoleType clientRole,
required bool muted,
String? publisherToken,
}) {
if (clientRole != ClientRoleType.clientRoleBroadcaster) {
_agoraRoleIsBroadcaster = false;
@ -1218,19 +1230,39 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (engine == null) {
_lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null;
_lastAppliedRecordingSignalMuted = null;
_lastAppliedRtcToken = null;
return;
}
final normalizedPublisherToken = (publisherToken ?? "").trim();
if (clientRole == ClientRoleType.clientRoleBroadcaster &&
normalizedPublisherToken.isNotEmpty &&
_lastAppliedRtcToken != normalizedPublisherToken) {
engine?.renewToken(normalizedPublisherToken);
_lastAppliedRtcToken = normalizedPublisherToken;
}
if (_lastAppliedClientRole != clientRole) {
engine?.setClientRole(role: clientRole);
_lastAppliedClientRole = clientRole;
}
if (!muted && _lastAppliedLocalAudioMuted != false) {
adjustRecordingSignalVolume(100);
final shouldPublishLocalAudio =
clientRole == ClientRoleType.clientRoleBroadcaster;
final localAudioMuted = !shouldPublishLocalAudio;
if (_lastAppliedLocalAudioMuted != localAudioMuted) {
engine?.muteLocalAudioStream(localAudioMuted);
_lastAppliedLocalAudioMuted = localAudioMuted;
}
if (_lastAppliedLocalAudioMuted != muted) {
engine?.muteLocalAudioStream(muted);
_lastAppliedLocalAudioMuted = muted;
final recordingSignalMuted = shouldPublishLocalAudio ? muted : true;
if (_lastAppliedRecordingSignalMuted != recordingSignalMuted) {
engine?.muteRecordingSignal(recordingSignalMuted);
_lastAppliedRecordingSignalMuted = recordingSignalMuted;
}
if (shouldPublishLocalAudio && !recordingSignalMuted) {
adjustRecordingSignalVolume(100);
}
}
@ -1243,6 +1275,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void _resetLocalAudioRuntimeTracking() {
_lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null;
_lastAppliedRecordingSignalMuted = null;
_lastAppliedRtcToken = null;
}
void _applyMicSnapshot(
@ -1561,14 +1595,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
try {
if (publisherToken.trim().isNotEmpty) {
await engine?.renewToken(publisherToken);
_lastAppliedRtcToken = publisherToken.trim();
}
await engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
await engine?.muteLocalAudioStream(false);
await engine?.muteRecordingSignal(muted);
if (!muted) {
adjustRecordingSignalVolume(100);
await engine?.adjustRecordingSignalVolume(100);
}
await engine?.muteLocalAudioStream(muted);
_lastAppliedClientRole = ClientRoleType.clientRoleBroadcaster;
_lastAppliedLocalAudioMuted = muted;
_lastAppliedLocalAudioMuted = false;
_lastAppliedRecordingSignalMuted = muted;
_agoraRoleIsBroadcaster = true;
_resyncHeartbeatFromAgoraState();
return true;
@ -1584,8 +1621,11 @@ class RealTimeCommunicationManager extends ChangeNotifier {
try {
await engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
await engine?.muteLocalAudioStream(true);
await engine?.muteRecordingSignal(true);
_lastAppliedClientRole = ClientRoleType.clientRoleAudience;
_lastAppliedLocalAudioMuted = true;
_lastAppliedRecordingSignalMuted = true;
_lastAppliedRtcToken = null;
_agoraRoleIsBroadcaster = false;
_resyncHeartbeatFromAgoraState();
return true;
@ -4028,6 +4068,12 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (filePath.trim().isEmpty) {
return;
}
if (!loopback) {
final canPublish = await _ensureRoomMusicPublishReady();
if (!canPublish) {
throw StateError('Current user is not ready to publish room music');
}
}
await engine?.startAudioMixing(
filePath: filePath,
loopback: loopback,
@ -4065,4 +4111,18 @@ class RealTimeCommunicationManager extends ChangeNotifier {
await engine?.adjustAudioMixingPlayoutVolume(safeVolume);
await engine?.adjustAudioMixingPublishVolume(safeVolume);
}
Future<bool> _ensureRoomMusicPublishReady() async {
if (!_agoraJoined || engine == null) {
return false;
}
final currentUserMic = _currentUserMicSeat();
if (currentUserMic == null) {
return false;
}
return _switchAgoraToBroadcaster(
publisherToken: currentUserMic.roomToken ?? "",
muted: (currentUserMic.micMute ?? false) || isMic,
);
}
}

View File

@ -68,30 +68,93 @@ class SCTaskListRes {
map['route'],
map['actionUrl'],
map['targetUrl'],
_nestedValue(map, 'jumpPage'),
_nestedValue(map, 'jumpUrl'),
_nestedValue(map, 'route'),
_nestedValue(map, 'targetUrl'),
]);
_jumpType = _firstString([
map['jumpType'],
map['actionType'],
map['clickType'],
map['targetType'],
_nestedValue(map, 'jumpType'),
_nestedValue(map, 'actionType'),
_nestedValue(map, 'targetType'),
]);
_roomId = _firstString([
map['roomId'],
map['targetRoomId'],
map['jumpRoomId'],
_nestedValue(map, 'roomId'),
_nestedValue(map, 'targetRoomId'),
_nestedValue(map, 'jumpRoomId'),
]);
_conditionType = _firstString([
map['conditionType'],
map['conditionCode'],
_nestedValue(map, 'conditionType'),
_nestedValue(map, 'conditionCode'),
]);
_conditionType = _asString(map['conditionType']);
_sortOrder = _asNum(map['sortOrder']);
_taskId = _asString(map['taskId']);
_taskStatus = _asNum(map['taskStatus']);
_cover = _asString(map['cover']);
_taskIcon = _asString(map['taskIcon']);
_conditionValue = _asString(map['conditionValue']);
_completedValue = _asNum(map['completedValue']);
_conditionValue =
_asString(map['conditionValue']) ??
_asString(_nestedValue(map, 'conditionValue'));
_completedValue = _firstNum([
map['completedValue'],
map['currentValue'],
map['currentProgress'],
map['progressValue'],
map['finishValue'],
map['finishedValue'],
map['completedCount'],
map['completeCount'],
map['finishCount'],
map['finishedCount'],
map['doneCount'],
map['current'],
map['currentNum'],
map['currentAmount'],
_nestedValue(map, 'completedValue'),
_nestedValue(map, 'currentValue'),
_nestedValue(map, 'current'),
_nestedValue(map, 'progressValue'),
_nestedValue(map, 'completedCount'),
_nestedValue(map, 'currentNum'),
_nestedValue(map, 'currentAmount'),
]);
_isRewardCollected = _asNum(map['isRewardCollected']);
_quantity = _asNum(map['quantity']);
_targetValue = _asNum(map['targetValue']);
_targetValue = _firstNum([
map['targetValue'],
map['target'],
map['goalValue'],
map['requiredValue'],
map['conditionValue'],
map['conditionNum'],
map['targetCount'],
map['requiredCount'],
map['targetNum'],
map['requiredNum'],
map['total'],
_nestedValue(map, 'targetValue'),
_nestedValue(map, 'target'),
_nestedValue(map, 'goalValue'),
_nestedValue(map, 'requiredValue'),
_nestedValue(map, 'conditionValue'),
_nestedValue(map, 'targetNum'),
_nestedValue(map, 'requiredNum'),
_nestedValue(map, 'total'),
]);
if (_completedValue == null &&
_targetValue != null &&
(_taskStatus ?? 0) == 1) {
_completedValue = _targetValue;
}
}
num? _taskType;
@ -208,6 +271,15 @@ class SCTaskListRes {
'extra',
'ext',
'extend',
'progress',
'taskProgress',
'condition',
'taskCondition',
'completion',
'completionInfo',
'progressInfo',
'stat',
'stats',
];
for (final nestedKey in nestedKeys) {
final nested = _asMap(map[nestedKey]);
@ -220,7 +292,21 @@ class SCTaskListRes {
static num? _asNum(dynamic value) {
if (value is num) return value;
if (value is String) return num.tryParse(value);
if (value is String) {
final text = value.trim();
if (text.isEmpty) return null;
return num.tryParse(text) ?? num.tryParse(text.replaceAll(',', ''));
}
return null;
}
static num? _firstNum(Iterable<dynamic> values) {
for (final value in values) {
final number = _asNum(value);
if (number != null) {
return number;
}
}
return null;
}
}

View File

@ -1020,7 +1020,9 @@ class SCAccountRepository implements SocialChatUserRepository {
return "{id=${task.taskId},type=${task.taskType},"
"status=${task.taskStatus},collected=${task.isRewardCollected},"
"current=${task.completedValue},target=${task.targetValue},"
"quantity=${task.quantity},name=${task.taskName}}";
"quantity=${task.quantity},name=${task.taskName},"
"condition=${task.conditionType},jumpType=${task.jumpType},"
"jumpPage=${task.jumpPage},roomId=${task.roomId}}";
})
.join(" ");
return "sample=$sample";

View File

@ -605,8 +605,22 @@ class _RewardCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final horizontalPadding = item.isFinalDay ? 12 * scale : 7 * scale;
final topPadding = item.isFinalDay ? 12 * scale : 18 * scale;
final bottomPadding = item.isFinalDay ? 5 * scale : 7 * scale;
final topPadding =
item.isCoinReward
? (item.isFinalDay ? 10 * scale : 14 * scale)
: (item.isFinalDay ? 12 * scale : 18 * scale);
final bottomPadding =
item.isCoinReward
? 4 * scale
: (item.isFinalDay ? 5 * scale : 7 * scale);
final coverSpacing = item.isCoinReward ? 3 * scale : 5 * scale;
final subtitleSpacing = item.isCoinReward ? 1 * scale : 2 * scale;
final double titleFontSize =
item.isCoinReward
? (item.isFinalDay ? 8.5 : 8.0)
: (item.isFinalDay ? 9.0 : 8.2);
final double subtitleFontSize = item.isCoinReward ? 6.5 : 7.0;
final double titleLineHeight = item.isCoinReward ? 1.0 : 1.2;
final titleColor =
item.status == DailySignInRewardStatus.current
@ -671,23 +685,24 @@ class _RewardCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_RewardCover(item: item, scale: scale),
SizedBox(height: 5 * scale),
SizedBox(height: coverSpacing),
text(
item.title,
fontSize: item.isFinalDay ? 9 : 8.2,
fontSize: titleFontSize,
fontWeight: FontWeight.w700,
maxLines: 2,
lineHeight: 1.2,
lineHeight: titleLineHeight,
textAlign: TextAlign.center,
textColor: titleColor,
),
if (item.subtitle.isNotEmpty) ...[
SizedBox(height: 2 * scale),
SizedBox(height: subtitleSpacing),
text(
item.subtitle,
fontSize: 7,
fontSize: subtitleFontSize,
fontWeight: FontWeight.w600,
maxLines: 1,
lineHeight: 1.0,
textAlign: TextAlign.center,
textColor: subtitleColor,
),
@ -727,7 +742,7 @@ class _RewardCover extends StatelessWidget {
Widget build(BuildContext context) {
final size =
item.isCoinReward
? (item.isFinalDay ? 36 * scale : 34 * scale)
? (item.isFinalDay ? 31 * scale : 29 * scale)
: (item.isFinalDay ? 30 * scale : 28 * scale);
final cover = item.cover;
final borderColor =

View File

@ -64,6 +64,14 @@ class _RoomRedPacketClaimRecordPageState
_RoomRedPacketClaimRecordTopBar(
title: localizations.roomRedPacketClaimRecordsTitle,
),
_RoomRedPacketClaimSummary(record: widget.record),
Divider(
height: 1.w,
thickness: 0.5.w,
indent: 16.w,
endIndent: 16.w,
color: Colors.white.withValues(alpha: 0.08),
),
Expanded(
child:
_loading
@ -156,6 +164,98 @@ class _RoomRedPacketClaimRecordPageState
}
}
class _RoomRedPacketClaimSummary extends StatelessWidget {
const _RoomRedPacketClaimSummary({required this.record});
final SCRoomRedPacketSentRecordRes record;
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
return Padding(
padding: EdgeInsetsDirectional.fromSTEB(16.w, 8.w, 16.w, 8.w),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: 58.w),
child: Row(
textDirection: Directionality.of(context),
children: [
Image.asset(
'sc_images/room/red_packet/small_packet_icon.png',
width: 36.w,
height: 36.w,
fit: BoxFit.contain,
),
SizedBox(width: 10.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizations.roomRedPacketHistoryItemTitle(
record.totalCount.round().toString(),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 4.w),
Text(
_formatHistoryTime(record.createTime),
textDirection: TextDirection.ltr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
],
),
),
SizedBox(width: 8.w),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatSignedAmount(-record.totalAmount.round()),
textDirection: TextDirection.ltr,
maxLines: 1,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w600,
),
),
if (record.refundAmount > 0) ...[
SizedBox(height: 3.w),
Text(
localizations.roomRedPacketHistoryRefund(
_bidiSafeLtr(_formatAmount(record.refundAmount.round())),
),
maxLines: 1,
style: TextStyle(
color: const Color(0xFFFF332B),
fontSize: 9.sp,
fontWeight: FontWeight.w500,
),
),
],
],
),
],
),
),
);
}
}
class _RoomRedPacketClaimRow extends StatelessWidget {
const _RoomRedPacketClaimRow({required this.item});
@ -349,6 +449,11 @@ String _formatAmount(int value) {
return buffer.toString();
}
String _formatSignedAmount(int value) {
final sign = value < 0 ? '-' : '+';
return '$sign${_formatAmount(value.abs())}';
}
String _formatHistoryTime(String raw) {
final parsed = DateTime.tryParse(raw);
if (parsed == null) {
@ -358,3 +463,5 @@ String _formatHistoryTime(String raw) {
return '${parsed.year}.${two(parsed.month)}.${two(parsed.day)} '
'${two(parsed.hour)}:${two(parsed.minute)}:${two(parsed.second)}';
}
String _bidiSafeLtr(String value) => '\u200E$value\u200E';

View File

@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/main.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
@ -536,7 +538,12 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
SCTts.show(localizations.roomRedPacketSendSuccess);
} catch (error) {
if (mounted) {
SCTts.show(roomRedPacketErrorMessage(context, error));
if (roomRedPacketErrorCode(error) ==
'voice_room_red_packet_insufficient_balance') {
_showInsufficientBalanceRechargeSheet();
} else {
SCTts.show(roomRedPacketErrorMessage(context, error));
}
}
} finally {
if (mounted) {
@ -622,6 +629,11 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
);
}
void _showInsufficientBalanceRechargeSheet() {
SmartDialog.dismiss(tag: RoomRedPacketSendPanel.dialogTag);
_RoomRedPacketRechargeSheet.show(context);
}
void _openRulePage() {
final navigator = navigatorKey.currentState;
SmartDialog.dismiss(tag: RoomRedPacketSendPanel.dialogTag);
@ -682,6 +694,225 @@ class _PickupOption {
final int minutes;
}
class _RoomRedPacketRechargeSheet extends StatelessWidget {
const _RoomRedPacketRechargeSheet();
static const String dialogTag = 'showRoomRedPacketRechargeSheet';
static void show(BuildContext context) {
SmartDialog.dismiss(tag: dialogTag);
SmartDialog.show(
tag: dialogTag,
alignment: Alignment.bottomCenter,
animationType: SmartAnimationType.fade,
maskColor: Colors.black.withValues(alpha: 0.45),
clickMaskDismiss: true,
builder: (_) => const _RoomRedPacketRechargeSheet(),
);
}
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context)!;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final minSheetHeight = 320.w + bottomPadding;
final preferredSheetHeight = MediaQuery.of(context).size.height * 0.46;
return Container(
width: double.infinity,
height:
preferredSheetHeight < minSheetHeight
? minSheetHeight
: preferredSheetHeight,
padding: EdgeInsets.fromLTRB(22.w, 10.w, 22.w, 18.w + bottomPadding),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFFFBF5), Color(0xFFFFEFE1)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.vertical(top: Radius.circular(22.w)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.22),
blurRadius: 18.w,
offset: Offset(0, -4.w),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 42.w,
height: 4.w,
decoration: BoxDecoration(
color: const Color(0xFFD6B28A).withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(2.w),
),
),
SizedBox(height: 8.w),
Align(
alignment: AlignmentDirectional.centerEnd,
child: SCDebounceWidget(
onTap: () => SmartDialog.dismiss(tag: dialogTag),
child: Icon(
Icons.close_rounded,
color: const Color(0xFF8C4A33),
size: 24.w,
),
),
),
Image.asset(
_RoomRedPacketAssets.amountIcon,
width: 52.w,
height: 52.w,
fit: BoxFit.contain,
),
SizedBox(height: 12.w),
Text(
localizations.roomRedPacketInsufficientBalance,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF8D2F2A),
fontSize: 20.sp,
height: 1.18,
fontWeight: FontWeight.w800,
decoration: TextDecoration.none,
),
),
SizedBox(height: 8.w),
Text(
localizations.insufhcientGoldsGoToRecharge,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF9B604C),
fontSize: 13.sp,
height: 1.35,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
),
SizedBox(height: 18.w),
Consumer<SocialChatUserProfileManager>(
builder: (context, profileManager, child) {
return Container(
width: double.infinity,
height: 46.w,
padding: EdgeInsets.symmetric(horizontal: 14.w),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(14.w),
border: Border.all(
color: const Color(0xFFF2D2A2),
width: 1.w,
),
),
child: Row(
children: [
Image.asset(
_RoomRedPacketAssets.amountIcon,
width: 24.w,
height: 24.w,
fit: BoxFit.contain,
),
SizedBox(width: 8.w),
Expanded(
child: Text(
localizations.wallet,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFF7B4133),
fontSize: 14.sp,
fontWeight: FontWeight.w700,
decoration: TextDecoration.none,
),
),
),
Text(
_formatSheetNumber(profileManager.myBalance.round()),
style: TextStyle(
color: const Color(0xFFE33C35),
fontSize: 15.sp,
fontWeight: FontWeight.w800,
decoration: TextDecoration.none,
),
),
],
),
);
},
),
const Spacer(),
SCDebounceWidget(
onTap: () => _goRecharge(context),
child: Container(
width: double.infinity,
height: 50.w,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFF4741), Color(0xFFE91E26)],
begin: AlignmentDirectional.centerStart,
end: AlignmentDirectional.centerEnd,
),
borderRadius: BorderRadius.circular(25.w),
border: Border.all(color: const Color(0xFFFFD282), width: 1.w),
boxShadow: [
BoxShadow(
color: const Color(0xFFE22D25).withValues(alpha: 0.28),
blurRadius: 12.w,
offset: Offset(0, 4.w),
),
],
),
child: Text(
localizations.goToRecharge,
style: TextStyle(
color: Colors.white,
fontSize: 17.sp,
fontWeight: FontWeight.w800,
decoration: TextDecoration.none,
),
),
),
),
],
),
);
}
void _goRecharge(BuildContext context) {
final routeContext = navigatorKey.currentState?.context ?? context;
SmartDialog.dismiss(tag: dialogTag);
SCNavigatorUtils.push(
routeContext,
WalletRoute.recharge,
replace: false,
).then((_) {
if (!routeContext.mounted) {
return;
}
Provider.of<SocialChatUserProfileManager>(
routeContext,
listen: false,
).balance();
});
}
static String _formatSheetNumber(int value) {
final raw = value.toString();
final buffer = StringBuffer();
for (int i = 0; i < raw.length; i++) {
if (i > 0 && (raw.length - i) % 3 == 0) {
buffer.write(',');
}
buffer.write(raw[i]);
}
return buffer.toString();
}
}
class _RoomRedPacketAssets {
static const String _base = 'sc_images/room/red_packet';
static const String sheetBg = '$_base/sheet_bg.png';

View File

@ -13,7 +13,7 @@ import 'package:yumi/shared/tools/sc_pick_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart';
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart';
import '../../../app/routes/sc_fluro_navigator.dart';
import '../../../modules/index/main_route.dart';
import '../../../modules/room/voice_room_route.dart';
@ -39,8 +39,8 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
static const int _menuReport = 5;
static const int _menuBackground = 6;
static const int _menuMusic = 7;
static const int _menuRedPacket = 8;
static bool get _showBackgroundFeature => true;
static const int _menuRedPacket = 8;
static bool get _showBackgroundFeature => true;
List<RoomMenu> items2 = [];
@ -96,13 +96,13 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
"sc_icon_room_msg_pic.png",
),
);
items2.add(
RoomMenu(
_menuRedPacket,
SCAppLocalizations.of(context)!.redEnvelope,
"red_packet/menu_red_packet_icon.png",
),
);
items2.add(
RoomMenu(
_menuRedPacket,
SCAppLocalizations.of(context)!.redEnvelope,
"red_packet/small_packet_icon.png",
),
);
if (Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
RoomMenu(
@ -272,9 +272,9 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
}, neeCrop: false);
}
} else if (item.id == _menuRedPacket) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
RoomRedPacketSendPanel.show(navigatorKey.currentState!.context);
} else if (item.id == _menuRedPacket) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
RoomRedPacketSendPanel.show(navigatorKey.currentState!.context);
} else if (item.id == _menuSettings) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
VoiceRoomRoute.openRoomEdit(
@ -306,7 +306,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
}
Widget _buildMenuIcon(RoomMenu item) {
if (item.id != _menuMusic && item.id != _menuRedPacket) {
if (item.id != _menuMusic && item.id != _menuRedPacket) {
return Image.asset(
"sc_images/room/${item.icon}",
width: 45.w,
@ -323,8 +323,8 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/${item.icon}",
width: item.id == _menuRedPacket ? 24.w : 26.w,
height: item.id == _menuRedPacket ? 28.w : 26.w,
width: item.id == _menuRedPacket ? 32.w : 26.w,
height: item.id == _menuRedPacket ? 32.w : 26.w,
),
);
}

View File

@ -9,7 +9,6 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
@ -20,6 +19,7 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/business_logic/models/res/mic_res.dart';
import 'package:yumi/shared/business_logic/usecases/sc_case.dart';
import 'package:yumi/modules/index/main_route.dart';
import 'package:yumi/ui_kit/widgets/sc_nine_patch_image.dart';
import 'package:yumi/ui_kit/widgets/room/room_emoji_asset_image.dart';
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_chat_message.dart';
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart';
@ -31,6 +31,14 @@ import '../../components/sc_compontent.dart';
import '../../components/sc_tts.dart';
import '../../components/text/sc_text.dart';
const EdgeInsets _vipBubbleCenterSliceRatio = EdgeInsets.fromLTRB(
0.34,
0.34,
0.34,
0.34,
);
const EdgeInsets _vipBubbleSourceEdgeInset = EdgeInsets.all(2);
///item
class MsgItem extends StatefulWidget {
final Function(SocialChatUserProfile? user) onClick;
@ -418,6 +426,8 @@ class _MsgItemState extends State<MsgItem> {
width: width,
height: height,
fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: SizedBox(width: width, height: height),
)
: _buildStaticVipBubbleImage(
@ -441,6 +451,8 @@ class _MsgItemState extends State<MsgItem> {
width: width,
height: height,
fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: fallback,
);
}
@ -463,22 +475,13 @@ class _MsgItemState extends State<MsgItem> {
return fallback ?? SizedBox(width: width, height: height);
}
return Image(
image: buildCachedImageProvider(
imageUrl,
logicalWidth: width,
logicalHeight: height,
),
return SCNinePatchImage(
url: imageUrl,
width: width,
height: height,
fit: BoxFit.fill,
filterQuality: FilterQuality.low,
errorBuilder: (context, error, stackTrace) {
debugPrint(
'[VIP][RoomChatBubble] image load failed url=$imageUrl error=$error',
);
return fallback ?? SizedBox(width: width, height: height);
},
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: fallback ?? SizedBox(width: width, height: height),
);
}
@ -495,7 +498,7 @@ class _MsgItemState extends State<MsgItem> {
}
String _resolveRoomChatBubbleImageUrl(PropsResources? resource) {
return _firstNonBlank([
final candidates = <String?>[
resource?.roomSendCoverUrl,
resource?.roomOpenedUrl,
resource?.roomNotOpenedUrl,
@ -506,6 +509,10 @@ class _MsgItemState extends State<MsgItem> {
resource?.cover,
resource?.expand,
resource?.sourceUrl,
];
return _firstNonBlank([
...candidates.where((value) => SCNetworkSvgaWidget.isSvga(value)),
...candidates,
]);
}

View File

@ -86,31 +86,18 @@ class _RoomRedPacketFloatingEntry extends StatelessWidget {
);
},
child: SizedBox(
width: 44.w,
height: 44.w,
width: 46.w,
height: 46.w,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Container(
width: 42.w,
height: 42.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.22),
border: Border.all(
color: const Color(0xFFFFD56A),
width: 1.3.w,
),
),
alignment: Alignment.center,
child: Image.asset(
'sc_images/room/red_packet/small_packet_icon.png',
width: 32.w,
height: 32.w,
fit: BoxFit.contain,
gaplessPlayback: true,
),
Image.asset(
'sc_images/room/red_packet/small_packet_icon.png',
width: 44.w,
height: 44.w,
fit: BoxFit.contain,
gaplessPlayback: true,
),
if (snapshot.packets.length > 1)
PositionedDirectional(

View File

@ -18,6 +18,8 @@ class SCNetworkSvgaWidget extends StatefulWidget {
this.fit = BoxFit.contain,
this.filterQuality = FilterQuality.low,
this.allowDrawingOverflow = false,
this.centerSliceRatio,
this.sourceEdgeInset = EdgeInsets.zero,
this.fallback,
this.dynamicIdentity,
this.movieConfigurer,
@ -31,6 +33,8 @@ class SCNetworkSvgaWidget extends StatefulWidget {
final BoxFit fit;
final FilterQuality filterQuality;
final bool allowDrawingOverflow;
final EdgeInsets? centerSliceRatio;
final EdgeInsets sourceEdgeInset;
final Widget? fallback;
final Object? dynamicIdentity;
final SCNetworkSvgaMovieConfigurer? movieConfigurer;
@ -147,11 +151,14 @@ class _SCNetworkSvgaWidgetState extends State<SCNetworkSvgaWidget>
@override
Widget build(BuildContext context) {
final videoItem = _controller.videoItem;
if (_hasError ||
_controller.videoItem == null ||
videoItem == null ||
_loadedResource != widget.resource.trim()) {
return _buildFallback();
}
final ninePatchSourceRect = _buildNinePatchSourceRect(videoItem);
final centerSlice = _buildCenterSlice(ninePatchSourceRect);
return SizedBox(
width: widget.width,
@ -163,6 +170,8 @@ class _SCNetworkSvgaWidgetState extends State<SCNetworkSvgaWidget>
clearsAfterStop: false,
filterQuality: widget.filterQuality,
allowDrawingOverflow: widget.allowDrawingOverflow,
centerSlice: centerSlice,
ninePatchSourceRect: centerSlice == null ? null : ninePatchSourceRect,
preferredSize:
widget.width != null && widget.height != null
? Size(widget.width!, widget.height!)
@ -176,4 +185,54 @@ class _SCNetworkSvgaWidgetState extends State<SCNetworkSvgaWidget>
return widget.fallback ??
SizedBox(width: widget.width, height: widget.height);
}
Rect _buildNinePatchSourceRect(MovieEntity movieEntity) {
final imageWidth = movieEntity.params.viewBoxWidth;
final imageHeight = movieEntity.params.viewBoxHeight;
final left =
widget.sourceEdgeInset.left.clamp(0.0, imageWidth / 4).toDouble();
final top =
widget.sourceEdgeInset.top.clamp(0.0, imageHeight / 4).toDouble();
final right =
widget.sourceEdgeInset.right.clamp(0.0, imageWidth / 4).toDouble();
final bottom =
widget.sourceEdgeInset.bottom.clamp(0.0, imageHeight / 4).toDouble();
return Rect.fromLTRB(left, top, imageWidth - right, imageHeight - bottom);
}
Rect? _buildCenterSlice(Rect sourceRect) {
final ratio = widget.centerSliceRatio;
if (ratio == null || sourceRect.isEmpty) {
return null;
}
final imageWidth = sourceRect.width;
final imageHeight = sourceRect.height;
if (imageWidth <= 3.0 || imageHeight <= 3.0) {
return null;
}
final left =
(sourceRect.left + imageWidth * ratio.left)
.clamp(sourceRect.left + 1.0, sourceRect.right - 2.0)
.toDouble();
final top =
(sourceRect.top + imageHeight * ratio.top)
.clamp(sourceRect.top + 1.0, sourceRect.bottom - 2.0)
.toDouble();
final rightInset =
(imageWidth * ratio.right)
.clamp(1.0, imageWidth - (left - sourceRect.left) - 1.0)
.toDouble();
final bottomInset =
(imageHeight * ratio.bottom)
.clamp(1.0, imageHeight - (top - sourceRect.top) - 1.0)
.toDouble();
final rect = Rect.fromLTRB(
left,
top,
sourceRect.right - rightInset,
sourceRect.bottom - bottomInset,
);
return rect.isEmpty ? null : rect;
}
}

View File

@ -6,6 +6,8 @@ class _SVGAPainter extends CustomPainter {
int get currentFrame => controller.currentFrame;
MovieEntity get videoItem => controller.videoItem!;
final FilterQuality filterQuality;
final Rect? centerSlice;
final Rect? ninePatchSourceRect;
/// Guaranteed to draw within the canvas bounds
final bool clipRect;
@ -13,6 +15,8 @@ class _SVGAPainter extends CustomPainter {
this.controller, {
this.fit = BoxFit.contain,
this.filterQuality = FilterQuality.low,
this.centerSlice,
this.ninePatchSourceRect,
this.clipRect = true,
}) : assert(
controller.videoItem != null, 'Invalid SVGAAnimationController!'),
@ -33,8 +37,145 @@ class _SVGAPainter extends CustomPainter {
try {
final canvasRect = Offset.zero & size;
if (clipRect) canvas.clipRect(canvasRect);
scaleCanvasToViewBox(canvas, canvasRect, Offset.zero & viewBoxSize);
drawSprites(canvas, size);
final slice = centerSlice;
if (slice != null && fit == BoxFit.fill) {
drawNinePatch(canvas, canvasRect, Offset.zero & viewBoxSize, slice);
} else {
scaleCanvasToViewBox(canvas, canvasRect, Offset.zero & viewBoxSize);
drawSprites(canvas, size);
}
} finally {
canvas.restore();
}
}
void drawNinePatch(
Canvas canvas,
Rect canvasRect,
Rect viewBoxRect,
Rect rawCenterSlice,
) {
final sourceRect = _validSourceRect(viewBoxRect);
if (sourceRect == null) {
scaleCanvasToViewBox(canvas, canvasRect, viewBoxRect);
drawSprites(canvas, canvasRect.size);
return;
}
final centerSlice = _validCenterSlice(sourceRect, rawCenterSlice);
if (centerSlice == null) {
scaleCanvasToViewBox(canvas, canvasRect, viewBoxRect);
drawSprites(canvas, canvasRect.size);
return;
}
final left = centerSlice.left - sourceRect.left;
final right = sourceRect.right - centerSlice.right;
final top = centerSlice.top - sourceRect.top;
final bottom = sourceRect.bottom - centerSlice.bottom;
final horizontalBorder = left + right;
final verticalBorder = top + bottom;
final horizontalScale = horizontalBorder <= 0
? 1.0
: min(1.0, canvasRect.width / horizontalBorder);
final verticalScale = verticalBorder <= 0
? 1.0
: min(1.0, canvasRect.height / verticalBorder);
final dstLeft = left * horizontalScale;
final dstRight = right * horizontalScale;
final dstTop = top * verticalScale;
final dstBottom = bottom * verticalScale;
final sourceXs = <double>[
sourceRect.left,
centerSlice.left,
centerSlice.right,
sourceRect.right,
];
final sourceYs = <double>[
sourceRect.top,
centerSlice.top,
centerSlice.bottom,
sourceRect.bottom,
];
final destXs = <double>[
canvasRect.left,
canvasRect.left + dstLeft,
canvasRect.right - dstRight,
canvasRect.right,
];
final destYs = <double>[
canvasRect.top,
canvasRect.top + dstTop,
canvasRect.bottom - dstBottom,
canvasRect.bottom,
];
for (var row = 0; row < 3; row++) {
for (var column = 0; column < 3; column++) {
final src = Rect.fromLTRB(
sourceXs[column],
sourceYs[row],
sourceXs[column + 1],
sourceYs[row + 1],
);
final dst = Rect.fromLTRB(
destXs[column],
destYs[row],
destXs[column + 1],
destYs[row + 1],
);
if (src.width <= 0 ||
src.height <= 0 ||
dst.width <= 0 ||
dst.height <= 0) {
continue;
}
_drawSourceSegment(canvas, src, dst);
}
}
}
Rect? _validSourceRect(Rect viewBoxRect) {
final rect = ninePatchSourceRect ?? viewBoxRect;
final left = rect.left
.clamp(viewBoxRect.left, viewBoxRect.right)
.toDouble();
final top = rect.top.clamp(viewBoxRect.top, viewBoxRect.bottom).toDouble();
final right = rect.right.clamp(left, viewBoxRect.right).toDouble();
final bottom = rect.bottom.clamp(top, viewBoxRect.bottom).toDouble();
final result = Rect.fromLTRB(left, top, right, bottom);
return result.isEmpty ? null : result;
}
Rect? _validCenterSlice(Rect sourceRect, Rect rawCenterSlice) {
final left = rawCenterSlice.left
.clamp(sourceRect.left, sourceRect.right)
.toDouble();
final top = rawCenterSlice.top
.clamp(sourceRect.top, sourceRect.bottom)
.toDouble();
final right = rawCenterSlice.right.clamp(left, sourceRect.right).toDouble();
final bottom = rawCenterSlice.bottom
.clamp(top, sourceRect.bottom)
.toDouble();
final result = Rect.fromLTRB(left, top, right, bottom);
return result.isEmpty ? null : result;
}
void _drawSourceSegment(Canvas canvas, Rect sourceRect, Rect destRect) {
canvas.save();
try {
canvas.clipRect(destRect);
canvas.translate(destRect.left, destRect.top);
canvas.scale(
destRect.width / sourceRect.width,
destRect.height / sourceRect.height,
);
canvas.translate(-sourceRect.left, -sourceRect.top);
drawSprites(canvas, destRect.size);
} finally {
canvas.restore();
}

View File

@ -36,6 +36,17 @@ class SVGAImage extends StatefulWidget {
///
/// Defaults to null.
final Size? preferredSize;
/// Stretchable center area in the SVGA viewBox coordinate space.
///
/// When supplied with [ninePatchSourceRect], the painter renders the SVGA like
/// a nine-patch image so borders and corners are not distorted.
final Rect? centerSlice;
/// Source area in the SVGA viewBox coordinate space used for nine-patch
/// drawing. Useful for skipping guide pixels around exported nine-patch art.
final Rect? ninePatchSourceRect;
const SVGAImage(
this._controller, {
super.key,
@ -44,6 +55,8 @@ class SVGAImage extends StatefulWidget {
this.allowDrawingOverflow,
this.clearsAfterStop = true,
this.preferredSize,
this.centerSlice,
this.ninePatchSourceRect,
});
@override
@ -285,6 +298,8 @@ class _SVGAImageState extends State<SVGAImage> {
widget._controller,
fit: widget.fit,
filterQuality: widget.filterQuality,
centerSlice: widget.centerSlice,
ninePatchSourceRect: widget.ninePatchSourceRect,
// default is allowing overflow for backward compatibility
clipRect: widget.allowDrawingOverflow == false,
),

View File

@ -37,7 +37,8 @@
- 第十步已为 Gift Wall 详情页补齐礼物名称/金额解析链路:礼物墙接口返回后会再用全局 `/gift/list` 配置表按 `standardId / id / giftCode / giftPhoto` 做二次匹配,补齐缺失的 `giftName / giftCandy / giftIntegral / giftPhoto`,用于单个礼物卡片和 `Total Value` 统计。
- 第十一步已补齐 Gift Wall 礼物动效预览:礼物卡点击时如果礼物 `giftSourceUrl``.svga`,会在当前页面全屏播放一次 SVGA 动效;播放期间使用页面状态锁防止重复叠加,播放完成或加载失败后自动清理 overlay 状态。
- 第十二步已补 Wall Of Honors 独立页面骨架:个人主页 `Wall Of Honors` 标题点击进入 `/main/person/wallOfHonors`;新页面先按无数据状态只展示满宽顶部返回栏、`My Achievements(0)` 标题、`Honors(0) / Event(0)` 两个 tab 和底部空态文案,顶部栏沿用满宽 `Stack` 写法避免返回箭头挤压标题。
- 验证进度:`dart format lib/modules/user/profile/profile_wall_of_honors_page.dart lib/modules/user/profile/person_detail_page.dart lib/modules/index/main_route.dart` 已执行;`flutter analyze --no-fatal-infos lib/modules/user/profile/profile_wall_of_honors_page.dart lib/modules/user/profile/profile_gift_wall_detail_page.dart lib/shared/business_logic/models/res/gift_res.dart lib/modules/user/profile/person_detail_page.dart lib/modules/index/main_route.dart` 无 error/warning仅保留旧文件原有的 info 级代码风格提示。下一步再继续逐项对齐资料页 UI。
- 第十三步已把个人主页里的墙模块改为预览态:`Gift Wall` 只保留前 9 个礼物用于主页预览,`Wall Of Honors` 当前无数据时也固定展示 9 个占位;进入独立 `Gift Wall / Wall Of Honors` 页面后再走详情页完整数据/页面逻辑。
- 验证进度:`dart format lib/modules/user/profile/person_detail_page.dart` 已执行;`flutter analyze --no-fatal-infos lib/modules/user/profile/person_detail_page.dart lib/modules/user/profile/profile_wall_of_honors_page.dart lib/modules/user/profile/profile_gift_wall_detail_page.dart lib/shared/business_logic/models/res/gift_res.dart lib/modules/index/main_route.dart` 无 error/warning仅保留旧文件原有的 info 级代码风格提示。下一步再继续逐项对齐资料页 UI。
## 本轮 Android 语言房音乐功能(进行中)
- 已查阅桌面《Android音乐添加流程.md》并产出可执行方案文档 `docs/android-room-music-execution-plan.md`覆盖功能范围、模块拆分、Android 权限、声网混音链路、UI 流程、风险点和 GPT 5.5 预估开发工时。
@ -546,6 +547,9 @@
- 已修正红包历史页和领取记录页的 RTL 排版:列表主文本列改回 `start` 对齐以跟随 RTL 靠右,金额列跟随 `end` 靠外侧;金额和时间强制按 LTR 渲染,避免阿语环境下时间顺序错乱或负号跑到数字后面。
- 已按最新接口文档接入红包领取记录新接口:领取记录页优先调用 `GET /go/app/voice-room/red-packet/claim-records?packetNo=...` 拉取领取人列表,失败时再兜底旧 detail records同时历史页和领取记录页跳转改为深色无默认转场 route避免进入页面时先闪一下白屏。
- 已将红包规则页替换为正式规则文案,并同步更新 `en/ar/bn/tr` 多语言;第 4 条每日房间红包发送上限不再写死,进入规则页时调用红包配置接口读取 `dailySendLimit` 后填入 `%1$s` 占位。
- 已补齐红包领取记录页的发包摘要:从发红包记录页点击进入后,顶部固定展示该红包图标、红包数量、发送时间、发出金额和退款金额;即使领取列表为空仍展示红包金额,空态只作为领取人列表区域提示。
- 已新增红包发送余额不足充值引导:发送接口返回 `voice_room_red_packet_insufficient_balance` 时关闭红包发送面板,改为弹出约半屏高度的底部充值弹窗,展示当前金币余额和充值按钮;点击充值复用 `me/wallet``WalletRoute.recharge` 进入充值页,返回后刷新用户余额。
- 已按反馈统一红包入口图标:房间右侧待领红包入口去掉黑底和金色外圈,直接展示放大的红包图标,尺寸与游戏入口接近;房间菜单内红包项也从白色线性图标改为同一张红包图标。
## 已知问题
- 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji``monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。