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": "يُعاد التعيين يوميًا عند منتصف الليل", "resetsDailyAtMidnight": "يُعاد التعيين يوميًا عند منتصف الليل",
"limitedToOneTime": "لمرة واحدة فقط", "limitedToOneTime": "لمرة واحدة فقط",
"useMicrophoneForOneMin": "استخدم الميكروفون لمدة دقيقة واحدة", "useMicrophoneForOneMin": "استخدم الميكروفون لمدة دقيقة واحدة",
"taskRequirement": "المتطلبات",
"taskProgress": "التقدم",
"rewardClaimedSuccessfully": "تم استلام المكافأة بنجاح", "rewardClaimedSuccessfully": "تم استلام المكافأة بنجاح",
"rewardClaimFailed": "فشل استلام المكافأة", "rewardClaimFailed": "فشل استلام المكافأة",
"nickName": "الاسم المستعار", "nickName": "الاسم المستعار",

View File

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

View File

@ -483,6 +483,8 @@
"resetsDailyAtMidnight": "Resets daily at midnight", "resetsDailyAtMidnight": "Resets daily at midnight",
"limitedToOneTime": "Limited to one time", "limitedToOneTime": "Limited to one time",
"useMicrophoneForOneMin": "Use the microphone for 1 min", "useMicrophoneForOneMin": "Use the microphone for 1 min",
"taskRequirement": "Requirement",
"taskProgress": "Progress",
"rewardClaimedSuccessfully": "Reward claimed successfully", "rewardClaimedSuccessfully": "Reward claimed successfully",
"rewardClaimFailed": "Reward claim failed", "rewardClaimFailed": "Reward claim failed",
"enterRoomConfirmTips": "Are you sure you want to enter the room?", "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", "resetsDailyAtMidnight": "Her gün gece yarısı sıfırlanır",
"limitedToOneTime": "Yalnızca bir kez", "limitedToOneTime": "Yalnızca bir kez",
"useMicrophoneForOneMin": "Mikrofonu 1 dakika kullan", "useMicrophoneForOneMin": "Mikrofonu 1 dakika kullan",
"taskRequirement": "Gereksinim",
"taskProgress": "İlerleme",
"rewardClaimedSuccessfully": "Ödül başarıyla alındı", "rewardClaimedSuccessfully": "Ödül başarıyla alındı",
"rewardClaimFailed": "Ödül alınamadı", "rewardClaimFailed": "Ödül alınamadı",
"enterRoomConfirmTips": "Odaya girmek istediğinizden emin misiniz?", "enterRoomConfirmTips": "Odaya girmek istediğinizden emin misiniz?",

View File

@ -906,6 +906,10 @@ class SCAppLocalizations {
String get useMicrophoneForOneMin => translate('useMicrophoneForOneMin'); String get useMicrophoneForOneMin => translate('useMicrophoneForOneMin');
String get taskRequirement => translate('taskRequirement');
String get taskProgress => translate('taskProgress');
String get rewardClaimedSuccessfully => String get rewardClaimedSuccessfully =>
translate('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_keybord_util.dart';
import 'package:yumi/shared/tools/sc_message_notifier.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_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/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.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/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart';
import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/modules/index/main_route.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 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart';
import '../../shared/business_logic/models/res/sc_user_red_packet_send_res.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) { String _resolveVipChatBubbleImageUrl(SCVipResourceRes? resource) {
return _firstNonBlankChatBubbleUrl([ final candidates = <String?>[
resource?.sourceResourceUrl,
resource?.previewUrl, resource?.previewUrl,
resource?.coverUrl, resource?.coverUrl,
resource?.cover, resource?.cover,
_staticChatBubbleUrl(resource?.sourceResourceUrl), ];
return _firstNonBlankChatBubbleUrl([
...candidates.where((value) => SCNetworkSvgaWidget.isSvga(value)),
...candidates,
]); ]);
} }
@ -83,14 +95,6 @@ String _firstNonBlankChatBubbleUrl(Iterable<String?> values) {
return ""; 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 { class SCMessageChatPage extends StatefulWidget {
final V2TimConversation? conversation; final V2TimConversation? conversation;
final bool shrinkWrap; final bool shrinkWrap;
@ -1698,6 +1702,8 @@ class _MessageItem extends StatelessWidget {
width: width, width: width,
height: height, height: height,
fit: BoxFit.fill, fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: SizedBox(width: width, height: height), fallback: SizedBox(width: width, height: height),
) )
: _buildStaticVipBubbleImage( : _buildStaticVipBubbleImage(
@ -1721,6 +1727,8 @@ class _MessageItem extends StatelessWidget {
width: width, width: width,
height: height, height: height,
fit: BoxFit.fill, fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: fallback, fallback: fallback,
); );
} }
@ -1743,22 +1751,13 @@ class _MessageItem extends StatelessWidget {
return fallback ?? SizedBox(width: width, height: height); return fallback ?? SizedBox(width: width, height: height);
} }
return Image( return SCNinePatchImage(
image: buildCachedImageProvider( url: imageUrl,
imageUrl,
logicalWidth: width,
logicalHeight: height,
),
width: width, width: width,
height: height, height: height,
fit: BoxFit.fill, centerSliceRatio: _vipBubbleCenterSliceRatio,
filterQuality: FilterQuality.low, sourceEdgeInset: _vipBubbleSourceEdgeInset,
errorBuilder: (context, error, stackTrace) { fallback: fallback ?? SizedBox(width: width, height: height),
debugPrint(
'[VIP][ChatBubble] image load failed url=$imageUrl error=$error',
);
return fallback ?? SizedBox(width: width, height: height);
},
); );
} }
@ -1775,7 +1774,7 @@ class _MessageItem extends StatelessWidget {
} }
String _resolveChatBubbleImageUrl(PropsResources? resource) { String _resolveChatBubbleImageUrl(PropsResources? resource) {
return _firstNonBlank([ final candidates = <String?>[
resource?.imSendCoverUrl, resource?.imSendCoverUrl,
resource?.imOpenedUrl, resource?.imOpenedUrl,
resource?.imOpenedUrlTwo, resource?.imOpenedUrlTwo,
@ -1786,6 +1785,10 @@ class _MessageItem extends StatelessWidget {
resource?.cover, resource?.cover,
resource?.expand, resource?.expand,
resource?.sourceUrl, 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 _profileBg = Color(0xff072121);
static const Color _cardBg = Color(0xff08251E); static const Color _cardBg = Color(0xff08251E);
static const Color _profileBorder = Color(0xffB2FBCC); static const Color _profileBorder = Color(0xffB2FBCC);
static const int _wallPreviewItemCount = 9;
SCUserIdentityRes? userIdentity; SCUserIdentityRes? userIdentity;
@ -149,7 +150,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
.giftWall(widget.tageId) .giftWall(widget.tageId)
.then((result) { .then((result) {
if (!mounted) return; if (!mounted) return;
giftWallList = result; giftWallList = result.take(_wallPreviewItemCount).toList();
setState(() {}); setState(() {});
}) })
.catchError((e) {}); .catchError((e) {});
@ -644,7 +645,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
), ),
SizedBox(height: 12.w), SizedBox(height: 12.w),
_buildWallGrid( _buildWallGrid(
itemCount: 4, itemCount: _wallPreviewItemCount,
itemBuilder: (_) => _buildHonorPlaceholderItem(), itemBuilder: (_) => _buildHonorPlaceholderItem(),
), ),
SizedBox(height: 18.w), SizedBox(height: 18.w),
@ -659,7 +660,10 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
), ),
SizedBox(height: 12.w), SizedBox(height: 12.w),
_buildWallGrid( _buildWallGrid(
itemCount: giftWallList.isEmpty ? 4 : giftWallList.length, itemCount:
giftWallList.isEmpty
? _wallPreviewItemCount
: giftWallList.length,
itemBuilder: (index) { itemBuilder: (index) {
final gift = final gift =
index < giftWallList.length ? giftWallList[index] : null; 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/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.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/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_room_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
@ -17,7 +18,8 @@ class TaskPage extends StatefulWidget {
State<TaskPage> createState() => _TaskPageState(); 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 _taskMicAsset = 'sc_images/index/sc_icon_task_mic.png';
static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png'; static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png';
static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png'; static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png';
@ -28,22 +30,59 @@ class _TaskPageState extends State<TaskPage> {
final Set<String> _claimingTaskIds = {}; final Set<String> _claimingTaskIds = {};
List<SCTaskListRes> _tasks = []; List<SCTaskListRes> _tasks = [];
bool _loading = true; bool _loading = true;
bool _loadingTasks = false;
String? _toastMessage; String? _toastMessage;
Timer? _toastTimer; Timer? _toastTimer;
PageRoute<dynamic>? _routeObserverRoute;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_loadTasks(); _loadTasks();
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
_toastTimer?.cancel(); _toastTimer?.cancel();
super.dispose(); 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 { Future<void> _loadTasks() async {
if (_loadingTasks) {
debugPrint('[TaskCenter][Page] load skipped: request in flight');
return;
}
_loadingTasks = true;
debugPrint('[TaskCenter][Page] load start'); debugPrint('[TaskCenter][Page] load start');
try { try {
final tasks = await _repository.tasks(); final tasks = await _repository.tasks();
@ -69,6 +108,8 @@ class _TaskPageState extends State<TaskPage> {
_tasks = []; _tasks = [];
_loading = false; _loading = false;
}); });
} finally {
_loadingTasks = false;
} }
} }
@ -255,13 +296,13 @@ class _TaskPageState extends State<TaskPage> {
_TaskUiItem _mapTask(SCTaskListRes task) { _TaskUiItem _mapTask(SCTaskListRes task) {
final id = task.taskId ?? ''; final id = task.taskId ?? '';
final currentValue = _parseNumber(task.completedValue) ?? 0; final currentValue = _parseNumber(task.completedValue) ?? 0;
final targetValue = final parsedTargetValue =
_parseNumber(task.targetValue) ?? _parseNumber(task.targetValue) ?? _parseNumber(task.conditionValue);
_parseNumber(task.conditionValue) ?? final targetValue = parsedTargetValue ?? 0;
1000000; final hasProgress = parsedTargetValue != null && parsedTargetValue > 0;
final claimed = final claimed =
_claimedTaskIds.contains(id) || (task.isRewardCollected ?? 0) == 1; _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 completedByStatus = (task.taskStatus ?? 0) == 1;
final status = final status =
claimed claimed
@ -275,16 +316,27 @@ class _TaskPageState extends State<TaskPage> {
title: title:
_nonEmpty(task.taskName) ?? _nonEmpty(task.taskName) ??
SCAppLocalizations.of(context)!.useMicrophoneForOneMin, SCAppLocalizations.of(context)!.useMicrophoneForOneMin,
progressText: progressText: _taskProgressText(
_nonEmpty(task.taskDesc) ?? task,
'${_formatPlainNumber(currentValue)}/${_formatPlainNumber(targetValue)}', currentValue: currentValue,
rewardText: _formatCompactNumber(task.quantity ?? 1100), targetValue: targetValue,
hasProgress: hasProgress,
),
requirementText: _taskRequirementText(
task,
targetValue: targetValue,
hasProgress: hasProgress,
),
rewardText: _formatPlainNumber(task.quantity ?? 1100),
status: status, status: status,
iconAsset: _iconForTask(task), iconAsset: _iconForTask(task),
iconUrl: _nonEmpty(task.taskIcon) ?? _nonEmpty(task.cover), iconUrl: _nonEmpty(task.taskIcon) ?? _nonEmpty(task.cover),
jumpPage: _nonEmpty(task.jumpPage), jumpPage: _nonEmpty(task.jumpPage),
jumpType: _nonEmpty(task.jumpType), jumpType: _nonEmpty(task.jumpType),
conditionType: _nonEmpty(task.conditionType),
roomId: _nonEmpty(task.roomId), roomId: _nonEmpty(task.roomId),
roomTask: _isRoomCompletionTask(task),
gameTask: _isGameCompletionTask(task),
fromApi: id.isNotEmpty, fromApi: id.isNotEmpty,
); );
} }
@ -292,12 +344,18 @@ class _TaskPageState extends State<TaskPage> {
Future<void> _handleTaskAction(_TaskUiItem task) async { Future<void> _handleTaskAction(_TaskUiItem task) async {
debugPrint( debugPrint(
'[TaskCenter][Page] action id=${task.id} status=${task.status.name} ' '[TaskCenter][Page] action id=${task.id} status=${task.status.name} '
'fromApi=${task.fromApi} jumpType=${task.jumpType} ' 'fromApi=${task.fromApi} conditionType=${task.conditionType} '
'jumpPage=${task.jumpPage} roomId=${task.roomId}', '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.claimed) return;
if (task.status == _TaskActionStatus.go) { 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; 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); var target = _taskJumpUrl(task);
if (target.isEmpty) return; if (target.isEmpty) return false;
var uri = Uri.tryParse(target); var uri = Uri.tryParse(target);
var path = uri?.path ?? ''; var path = uri?.path ?? '';
var roomId = uri?.queryParameters['roomId']?.trim() ?? ''; var roomId = uri?.queryParameters['roomId']?.trim() ?? '';
@ -336,10 +394,10 @@ class _TaskPageState extends State<TaskPage> {
'target=$target taskId=${task.id}', 'target=$target taskId=${task.id}',
); );
final fallbackTarget = await _firstRoomJumpUrl(target); final fallbackTarget = await _firstRoomJumpUrl(target);
if (!mounted) return; if (!mounted) return false;
if (fallbackTarget.isEmpty) { if (fallbackTarget.isEmpty) {
_showToast(SCAppLocalizations.of(context)!.enterRoomFailedRetry); _showToast(SCAppLocalizations.of(context)!.enterRoomFailedRetry);
return; return false;
} }
target = fallbackTarget; target = fallbackTarget;
uri = Uri.tryParse(target); uri = Uri.tryParse(target);
@ -357,6 +415,7 @@ class _TaskPageState extends State<TaskPage> {
jumpType: jumpType, jumpType: jumpType,
jumpUrl: target, jumpUrl: target,
); );
return true;
} }
Future<String> _firstRoomJumpUrl(String target) async { Future<String> _firstRoomJumpUrl(String target) async {
@ -393,19 +452,7 @@ class _TaskPageState extends State<TaskPage> {
final target = task.jumpPage?.trim() ?? ''; final target = task.jumpPage?.trim() ?? '';
final roomId = task.roomId?.trim() ?? ''; final roomId = task.roomId?.trim() ?? '';
if (target.isEmpty) { if (target.isEmpty) {
if (_isGameJumpType(task.jumpType)) { return _defaultTaskJumpUrl(task, roomId);
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)}';
} }
if (roomId.isEmpty) return target; if (roomId.isEmpty) return target;
@ -415,20 +462,42 @@ class _TaskPageState extends State<TaskPage> {
return _withRoomId(target, roomId); 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) { String _taskJumpType(_TaskUiItem task, String target) {
final uri = Uri.tryParse(target);
final path = uri?.path ?? '';
final jumpType = task.jumpType?.trim(); final jumpType = task.jumpType?.trim();
if (jumpType != null && jumpType.isNotEmpty) { if (jumpType != null && jumpType.isNotEmpty) {
if (_isGameJumpType(jumpType)) return 'GAME'; if (_isGameJumpType(jumpType)) return 'GAME';
if (_isRoomJumpType(jumpType)) return 'APP_ROUTE'; if (_isRoomJumpType(jumpType)) return 'APP_ROUTE';
return jumpType;
} }
final uri = Uri.tryParse(target);
if (uri?.hasScheme == true) { if (uri?.hasScheme == true) {
return 'H5'; return 'H5';
} }
if (uri?.path == '/game-center') { if (path == '/game-center') {
return 'GAME'; return 'GAME';
} }
if (path.startsWith('/')) {
return 'APP_ROUTE';
}
if (jumpType != null && jumpType.isNotEmpty) return jumpType;
return 'APP_ROUTE'; return 'APP_ROUTE';
} }
@ -436,11 +505,16 @@ class _TaskPageState extends State<TaskPage> {
final normalized = jumpType?.trim().toUpperCase() ?? ''; final normalized = jumpType?.trim().toUpperCase() ?? '';
return normalized == 'ROOM' || return normalized == 'ROOM' ||
normalized == 'VOICE_ROOM' || normalized == 'VOICE_ROOM' ||
normalized == 'VOICECHAT_ROOM'; normalized == 'VOICECHAT_ROOM' ||
normalized == 'ENTER_ROOM' ||
normalized == 'JOIN_ROOM';
} }
bool _isGameJumpType(String? jumpType) { 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) { void _showToast(String message) {
@ -461,9 +535,79 @@ class _TaskPageState extends State<TaskPage> {
return _taskDefaultAsset; 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) { static num? _parseNumber(Object? value) {
if (value is num) return 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; return null;
} }
@ -477,20 +621,49 @@ class _TaskPageState extends State<TaskPage> {
return value.toStringAsFixed(1); return value.toStringAsFixed(1);
} }
static String _formatCompactNumber(num value) { String _formatTaskValue(SCTaskListRes task, num value) {
final absValue = value.abs(); if (_isTimeValueTask(task)) return _formatDurationSeconds(value);
if (absValue >= 1000000) {
return '${_trimTrailingZero(value / 1000000)}m';
}
if (absValue >= 1000) {
return '${_trimTrailingZero(value / 1000)}k';
}
return _formatPlainNumber(value); return _formatPlainNumber(value);
} }
static String _trimTrailingZero(num value) { bool _isTimeValueTask(SCTaskListRes task) {
final text = value.toStringAsFixed(1); final conditionType = task.conditionType?.trim().toUpperCase() ?? '';
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text; 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) { String _taskDebugSample(List<SCTaskListRes> tasks) {
@ -502,6 +675,7 @@ class _TaskPageState extends State<TaskPage> {
'status=${task.taskStatus},collected=${task.isRewardCollected},' 'status=${task.taskStatus},collected=${task.isRewardCollected},'
'current=${task.completedValue},target=${task.targetValue},' '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},' 'jumpType=${task.jumpType},jumpPage=${task.jumpPage},'
'roomId=${task.roomId}}'; 'roomId=${task.roomId}}';
}) })
@ -671,7 +845,7 @@ class _TaskRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 65.w, height: 76.w,
child: Row( child: Row(
children: [ children: [
SizedBox(width: 12.w), SizedBox(width: 12.w),
@ -703,18 +877,35 @@ class _TaskRow extends StatelessWidget {
), ),
), ),
SizedBox(height: 3.w), SizedBox(height: 3.w),
Text( if (task.requirementText.isNotEmpty) ...[
task.progressText, Text(
maxLines: 1, task.requirementText,
overflow: TextOverflow.ellipsis, maxLines: 1,
style: TextStyle( overflow: TextOverflow.ellipsis,
color: Colors.white.withValues(alpha: 0.62), style: TextStyle(
fontSize: 10.sp, color: Colors.white.withValues(alpha: 0.72),
fontWeight: FontWeight.w400, fontSize: 10.sp,
height: 1, fontWeight: FontWeight.w400,
height: 1,
),
), ),
), SizedBox(height: 3.w),
SizedBox(height: 6.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( Row(
children: [ children: [
Image.asset( Image.asset(
@ -903,26 +1094,34 @@ class _TaskUiItem {
required this.id, required this.id,
required this.title, required this.title,
required this.progressText, required this.progressText,
required this.requirementText,
required this.rewardText, required this.rewardText,
required this.status, required this.status,
required this.iconAsset, required this.iconAsset,
this.iconUrl, this.iconUrl,
this.jumpPage, this.jumpPage,
this.jumpType, this.jumpType,
this.conditionType,
this.roomId, this.roomId,
this.roomTask = false,
this.gameTask = false,
this.fromApi = false, this.fromApi = false,
}); });
final String id; final String id;
final String title; final String title;
final String progressText; final String progressText;
final String requirementText;
final String rewardText; final String rewardText;
final _TaskActionStatus status; final _TaskActionStatus status;
final String iconAsset; final String iconAsset;
final String? iconUrl; final String? iconUrl;
final String? jumpPage; final String? jumpPage;
final String? jumpType; final String? jumpType;
final String? conditionType;
final String? roomId; final String? roomId;
final bool roomTask;
final bool gameTask;
final bool fromApi; final bool fromApi;
_TaskUiItem copyWith({_TaskActionStatus? status}) { _TaskUiItem copyWith({_TaskActionStatus? status}) {
@ -930,13 +1129,17 @@ class _TaskUiItem {
id: id, id: id,
title: title, title: title,
progressText: progressText, progressText: progressText,
requirementText: requirementText,
rewardText: rewardText, rewardText: rewardText,
status: status ?? this.status, status: status ?? this.status,
iconAsset: iconAsset, iconAsset: iconAsset,
iconUrl: iconUrl, iconUrl: iconUrl,
jumpPage: jumpPage, jumpPage: jumpPage,
jumpType: jumpType, jumpType: jumpType,
conditionType: conditionType,
roomId: roomId, roomId: roomId,
roomTask: roomTask,
gameTask: gameTask,
fromApi: fromApi, fromApi: fromApi,
); );
} }

View File

@ -350,6 +350,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
int? _pendingSelfMicReleaseGuardUntilMs; int? _pendingSelfMicReleaseGuardUntilMs;
ClientRoleType? _lastAppliedClientRole; ClientRoleType? _lastAppliedClientRole;
bool? _lastAppliedLocalAudioMuted; bool? _lastAppliedLocalAudioMuted;
bool? _lastAppliedRecordingSignalMuted;
String? _lastAppliedRtcToken;
bool? _lastScheduledVoiceLiveOnMic; bool? _lastScheduledVoiceLiveOnMic;
String? _lastScheduledVoiceLiveRoomId; String? _lastScheduledVoiceLiveRoomId;
String? _lastScheduledAnchorRoomId; String? _lastScheduledAnchorRoomId;
@ -1091,6 +1093,20 @@ class RealTimeCommunicationManager extends ChangeNotifier {
.connectionChangedLicenseValidationFailure; .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() { void _syncSelfMicRuntimeState() {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id; final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
if ((currentUserId ?? "").isEmpty) { if ((currentUserId ?? "").isEmpty) {
@ -1098,13 +1114,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
} }
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
MicRes? currentUserMic; final currentUserMic = _currentUserMicSeat();
for (final mic in roomWheatMap.values) {
if (mic.user?.id == currentUserId) {
currentUserMic = mic;
break;
}
}
if (currentUserMic == null) { if (currentUserMic == null) {
_clearSelfMicSwitchGuard(clearPreferredIndex: true); _clearSelfMicSwitchGuard(clearPreferredIndex: true);
@ -1133,6 +1143,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_applyLocalAudioRuntimeState( _applyLocalAudioRuntimeState(
clientRole: ClientRoleType.clientRoleBroadcaster, clientRole: ClientRoleType.clientRoleBroadcaster,
muted: (currentUserMic.micMute ?? false) || isMic, muted: (currentUserMic.micMute ?? false) || isMic,
publisherToken: currentUserMic.roomToken,
); );
_roomMusicMicRouteChangedListener?.call(true); _roomMusicMicRouteChangedListener?.call(true);
} }
@ -1211,6 +1222,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void _applyLocalAudioRuntimeState({ void _applyLocalAudioRuntimeState({
required ClientRoleType clientRole, required ClientRoleType clientRole,
required bool muted, required bool muted,
String? publisherToken,
}) { }) {
if (clientRole != ClientRoleType.clientRoleBroadcaster) { if (clientRole != ClientRoleType.clientRoleBroadcaster) {
_agoraRoleIsBroadcaster = false; _agoraRoleIsBroadcaster = false;
@ -1218,19 +1230,39 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (engine == null) { if (engine == null) {
_lastAppliedClientRole = null; _lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null; _lastAppliedLocalAudioMuted = null;
_lastAppliedRecordingSignalMuted = null;
_lastAppliedRtcToken = null;
return; return;
} }
final normalizedPublisherToken = (publisherToken ?? "").trim();
if (clientRole == ClientRoleType.clientRoleBroadcaster &&
normalizedPublisherToken.isNotEmpty &&
_lastAppliedRtcToken != normalizedPublisherToken) {
engine?.renewToken(normalizedPublisherToken);
_lastAppliedRtcToken = normalizedPublisherToken;
}
if (_lastAppliedClientRole != clientRole) { if (_lastAppliedClientRole != clientRole) {
engine?.setClientRole(role: clientRole); engine?.setClientRole(role: clientRole);
_lastAppliedClientRole = 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); final recordingSignalMuted = shouldPublishLocalAudio ? muted : true;
_lastAppliedLocalAudioMuted = muted; if (_lastAppliedRecordingSignalMuted != recordingSignalMuted) {
engine?.muteRecordingSignal(recordingSignalMuted);
_lastAppliedRecordingSignalMuted = recordingSignalMuted;
}
if (shouldPublishLocalAudio && !recordingSignalMuted) {
adjustRecordingSignalVolume(100);
} }
} }
@ -1243,6 +1275,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void _resetLocalAudioRuntimeTracking() { void _resetLocalAudioRuntimeTracking() {
_lastAppliedClientRole = null; _lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null; _lastAppliedLocalAudioMuted = null;
_lastAppliedRecordingSignalMuted = null;
_lastAppliedRtcToken = null;
} }
void _applyMicSnapshot( void _applyMicSnapshot(
@ -1561,14 +1595,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
try { try {
if (publisherToken.trim().isNotEmpty) { if (publisherToken.trim().isNotEmpty) {
await engine?.renewToken(publisherToken); await engine?.renewToken(publisherToken);
_lastAppliedRtcToken = publisherToken.trim();
} }
await engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster); await engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
await engine?.muteLocalAudioStream(false);
await engine?.muteRecordingSignal(muted);
if (!muted) { if (!muted) {
adjustRecordingSignalVolume(100); await engine?.adjustRecordingSignalVolume(100);
} }
await engine?.muteLocalAudioStream(muted);
_lastAppliedClientRole = ClientRoleType.clientRoleBroadcaster; _lastAppliedClientRole = ClientRoleType.clientRoleBroadcaster;
_lastAppliedLocalAudioMuted = muted; _lastAppliedLocalAudioMuted = false;
_lastAppliedRecordingSignalMuted = muted;
_agoraRoleIsBroadcaster = true; _agoraRoleIsBroadcaster = true;
_resyncHeartbeatFromAgoraState(); _resyncHeartbeatFromAgoraState();
return true; return true;
@ -1584,8 +1621,11 @@ class RealTimeCommunicationManager extends ChangeNotifier {
try { try {
await engine?.setClientRole(role: ClientRoleType.clientRoleAudience); await engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
await engine?.muteLocalAudioStream(true); await engine?.muteLocalAudioStream(true);
await engine?.muteRecordingSignal(true);
_lastAppliedClientRole = ClientRoleType.clientRoleAudience; _lastAppliedClientRole = ClientRoleType.clientRoleAudience;
_lastAppliedLocalAudioMuted = true; _lastAppliedLocalAudioMuted = true;
_lastAppliedRecordingSignalMuted = true;
_lastAppliedRtcToken = null;
_agoraRoleIsBroadcaster = false; _agoraRoleIsBroadcaster = false;
_resyncHeartbeatFromAgoraState(); _resyncHeartbeatFromAgoraState();
return true; return true;
@ -4028,6 +4068,12 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (filePath.trim().isEmpty) { if (filePath.trim().isEmpty) {
return; return;
} }
if (!loopback) {
final canPublish = await _ensureRoomMusicPublishReady();
if (!canPublish) {
throw StateError('Current user is not ready to publish room music');
}
}
await engine?.startAudioMixing( await engine?.startAudioMixing(
filePath: filePath, filePath: filePath,
loopback: loopback, loopback: loopback,
@ -4065,4 +4111,18 @@ class RealTimeCommunicationManager extends ChangeNotifier {
await engine?.adjustAudioMixingPlayoutVolume(safeVolume); await engine?.adjustAudioMixingPlayoutVolume(safeVolume);
await engine?.adjustAudioMixingPublishVolume(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['route'],
map['actionUrl'], map['actionUrl'],
map['targetUrl'], map['targetUrl'],
_nestedValue(map, 'jumpPage'),
_nestedValue(map, 'jumpUrl'),
_nestedValue(map, 'route'),
_nestedValue(map, 'targetUrl'),
]); ]);
_jumpType = _firstString([ _jumpType = _firstString([
map['jumpType'], map['jumpType'],
map['actionType'], map['actionType'],
map['clickType'], map['clickType'],
map['targetType'], map['targetType'],
_nestedValue(map, 'jumpType'),
_nestedValue(map, 'actionType'),
_nestedValue(map, 'targetType'),
]); ]);
_roomId = _firstString([ _roomId = _firstString([
map['roomId'], map['roomId'],
map['targetRoomId'], map['targetRoomId'],
map['jumpRoomId'], map['jumpRoomId'],
_nestedValue(map, 'roomId'), _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']); _sortOrder = _asNum(map['sortOrder']);
_taskId = _asString(map['taskId']); _taskId = _asString(map['taskId']);
_taskStatus = _asNum(map['taskStatus']); _taskStatus = _asNum(map['taskStatus']);
_cover = _asString(map['cover']); _cover = _asString(map['cover']);
_taskIcon = _asString(map['taskIcon']); _taskIcon = _asString(map['taskIcon']);
_conditionValue = _asString(map['conditionValue']); _conditionValue =
_completedValue = _asNum(map['completedValue']); _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']); _isRewardCollected = _asNum(map['isRewardCollected']);
_quantity = _asNum(map['quantity']); _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; num? _taskType;
@ -208,6 +271,15 @@ class SCTaskListRes {
'extra', 'extra',
'ext', 'ext',
'extend', 'extend',
'progress',
'taskProgress',
'condition',
'taskCondition',
'completion',
'completionInfo',
'progressInfo',
'stat',
'stats',
]; ];
for (final nestedKey in nestedKeys) { for (final nestedKey in nestedKeys) {
final nested = _asMap(map[nestedKey]); final nested = _asMap(map[nestedKey]);
@ -220,7 +292,21 @@ class SCTaskListRes {
static num? _asNum(dynamic value) { static num? _asNum(dynamic value) {
if (value is num) return 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; return null;
} }
} }

View File

@ -1020,7 +1020,9 @@ class SCAccountRepository implements SocialChatUserRepository {
return "{id=${task.taskId},type=${task.taskType}," return "{id=${task.taskId},type=${task.taskType},"
"status=${task.taskStatus},collected=${task.isRewardCollected}," "status=${task.taskStatus},collected=${task.isRewardCollected},"
"current=${task.completedValue},target=${task.targetValue}," "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(" "); .join(" ");
return "sample=$sample"; return "sample=$sample";

View File

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

View File

@ -64,6 +64,14 @@ class _RoomRedPacketClaimRecordPageState
_RoomRedPacketClaimRecordTopBar( _RoomRedPacketClaimRecordTopBar(
title: localizations.roomRedPacketClaimRecordsTitle, 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( Expanded(
child: child:
_loading _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 { class _RoomRedPacketClaimRow extends StatelessWidget {
const _RoomRedPacketClaimRow({required this.item}); const _RoomRedPacketClaimRow({required this.item});
@ -349,6 +449,11 @@ String _formatAmount(int value) {
return buffer.toString(); return buffer.toString();
} }
String _formatSignedAmount(int value) {
final sign = value < 0 ? '-' : '+';
return '$sign${_formatAmount(value.abs())}';
}
String _formatHistoryTime(String raw) { String _formatHistoryTime(String raw) {
final parsed = DateTime.tryParse(raw); final parsed = DateTime.tryParse(raw);
if (parsed == null) { if (parsed == null) {
@ -358,3 +463,5 @@ String _formatHistoryTime(String raw) {
return '${parsed.year}.${two(parsed.month)}.${two(parsed.day)} ' return '${parsed.year}.${two(parsed.month)}.${two(parsed.day)} '
'${two(parsed.hour)}:${two(parsed.minute)}:${two(parsed.second)}'; '${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_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.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_localizations.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/main.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/rtc_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart';
@ -536,7 +538,12 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
SCTts.show(localizations.roomRedPacketSendSuccess); SCTts.show(localizations.roomRedPacketSendSuccess);
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
SCTts.show(roomRedPacketErrorMessage(context, error)); if (roomRedPacketErrorCode(error) ==
'voice_room_red_packet_insufficient_balance') {
_showInsufficientBalanceRechargeSheet();
} else {
SCTts.show(roomRedPacketErrorMessage(context, error));
}
} }
} finally { } finally {
if (mounted) { if (mounted) {
@ -622,6 +629,11 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
); );
} }
void _showInsufficientBalanceRechargeSheet() {
SmartDialog.dismiss(tag: RoomRedPacketSendPanel.dialogTag);
_RoomRedPacketRechargeSheet.show(context);
}
void _openRulePage() { void _openRulePage() {
final navigator = navigatorKey.currentState; final navigator = navigatorKey.currentState;
SmartDialog.dismiss(tag: RoomRedPacketSendPanel.dialogTag); SmartDialog.dismiss(tag: RoomRedPacketSendPanel.dialogTag);
@ -682,6 +694,225 @@ class _PickupOption {
final int minutes; 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 { class _RoomRedPacketAssets {
static const String _base = 'sc_images/room/red_packet'; static const String _base = 'sc_images/room/red_packet';
static const String sheetBg = '$_base/sheet_bg.png'; static const String sheetBg = '$_base/sheet_bg.png';

View File

@ -100,7 +100,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
RoomMenu( RoomMenu(
_menuRedPacket, _menuRedPacket,
SCAppLocalizations.of(context)!.redEnvelope, SCAppLocalizations.of(context)!.redEnvelope,
"red_packet/menu_red_packet_icon.png", "red_packet/small_packet_icon.png",
), ),
); );
if (Provider.of<RtcProvider>(context, listen: false).isFz()) { if (Provider.of<RtcProvider>(context, listen: false).isFz()) {
@ -323,8 +323,8 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
alignment: Alignment.center, alignment: Alignment.center,
child: Image.asset( child: Image.asset(
"sc_images/room/${item.icon}", "sc_images/room/${item.icon}",
width: item.id == _menuRedPacket ? 24.w : 26.w, width: item.id == _menuRedPacket ? 32.w : 26.w,
height: item.id == _menuRedPacket ? 28.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_localizations.dart';
import 'package:yumi/app/constants/sc_global_config.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_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/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/app/constants/sc_room_msg_type.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/models/res/mic_res.dart';
import 'package:yumi/shared/business_logic/usecases/sc_case.dart'; import 'package:yumi/shared/business_logic/usecases/sc_case.dart';
import 'package:yumi/modules/index/main_route.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/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_chat_message.dart';
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.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/sc_tts.dart';
import '../../components/text/sc_text.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 ///item
class MsgItem extends StatefulWidget { class MsgItem extends StatefulWidget {
final Function(SocialChatUserProfile? user) onClick; final Function(SocialChatUserProfile? user) onClick;
@ -418,6 +426,8 @@ class _MsgItemState extends State<MsgItem> {
width: width, width: width,
height: height, height: height,
fit: BoxFit.fill, fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: SizedBox(width: width, height: height), fallback: SizedBox(width: width, height: height),
) )
: _buildStaticVipBubbleImage( : _buildStaticVipBubbleImage(
@ -441,6 +451,8 @@ class _MsgItemState extends State<MsgItem> {
width: width, width: width,
height: height, height: height,
fit: BoxFit.fill, fit: BoxFit.fill,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: fallback, fallback: fallback,
); );
} }
@ -463,22 +475,13 @@ class _MsgItemState extends State<MsgItem> {
return fallback ?? SizedBox(width: width, height: height); return fallback ?? SizedBox(width: width, height: height);
} }
return Image( return SCNinePatchImage(
image: buildCachedImageProvider( url: imageUrl,
imageUrl,
logicalWidth: width,
logicalHeight: height,
),
width: width, width: width,
height: height, height: height,
fit: BoxFit.fill, centerSliceRatio: _vipBubbleCenterSliceRatio,
filterQuality: FilterQuality.low, sourceEdgeInset: _vipBubbleSourceEdgeInset,
errorBuilder: (context, error, stackTrace) { fallback: fallback ?? SizedBox(width: width, height: height),
debugPrint(
'[VIP][RoomChatBubble] image load failed url=$imageUrl error=$error',
);
return fallback ?? SizedBox(width: width, height: height);
},
); );
} }
@ -495,7 +498,7 @@ class _MsgItemState extends State<MsgItem> {
} }
String _resolveRoomChatBubbleImageUrl(PropsResources? resource) { String _resolveRoomChatBubbleImageUrl(PropsResources? resource) {
return _firstNonBlank([ final candidates = <String?>[
resource?.roomSendCoverUrl, resource?.roomSendCoverUrl,
resource?.roomOpenedUrl, resource?.roomOpenedUrl,
resource?.roomNotOpenedUrl, resource?.roomNotOpenedUrl,
@ -506,6 +509,10 @@ class _MsgItemState extends State<MsgItem> {
resource?.cover, resource?.cover,
resource?.expand, resource?.expand,
resource?.sourceUrl, resource?.sourceUrl,
];
return _firstNonBlank([
...candidates.where((value) => SCNetworkSvgaWidget.isSvga(value)),
...candidates,
]); ]);
} }

View File

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

View File

@ -18,6 +18,8 @@ class SCNetworkSvgaWidget extends StatefulWidget {
this.fit = BoxFit.contain, this.fit = BoxFit.contain,
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
this.allowDrawingOverflow = false, this.allowDrawingOverflow = false,
this.centerSliceRatio,
this.sourceEdgeInset = EdgeInsets.zero,
this.fallback, this.fallback,
this.dynamicIdentity, this.dynamicIdentity,
this.movieConfigurer, this.movieConfigurer,
@ -31,6 +33,8 @@ class SCNetworkSvgaWidget extends StatefulWidget {
final BoxFit fit; final BoxFit fit;
final FilterQuality filterQuality; final FilterQuality filterQuality;
final bool allowDrawingOverflow; final bool allowDrawingOverflow;
final EdgeInsets? centerSliceRatio;
final EdgeInsets sourceEdgeInset;
final Widget? fallback; final Widget? fallback;
final Object? dynamicIdentity; final Object? dynamicIdentity;
final SCNetworkSvgaMovieConfigurer? movieConfigurer; final SCNetworkSvgaMovieConfigurer? movieConfigurer;
@ -147,11 +151,14 @@ class _SCNetworkSvgaWidgetState extends State<SCNetworkSvgaWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final videoItem = _controller.videoItem;
if (_hasError || if (_hasError ||
_controller.videoItem == null || videoItem == null ||
_loadedResource != widget.resource.trim()) { _loadedResource != widget.resource.trim()) {
return _buildFallback(); return _buildFallback();
} }
final ninePatchSourceRect = _buildNinePatchSourceRect(videoItem);
final centerSlice = _buildCenterSlice(ninePatchSourceRect);
return SizedBox( return SizedBox(
width: widget.width, width: widget.width,
@ -163,6 +170,8 @@ class _SCNetworkSvgaWidgetState extends State<SCNetworkSvgaWidget>
clearsAfterStop: false, clearsAfterStop: false,
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
allowDrawingOverflow: widget.allowDrawingOverflow, allowDrawingOverflow: widget.allowDrawingOverflow,
centerSlice: centerSlice,
ninePatchSourceRect: centerSlice == null ? null : ninePatchSourceRect,
preferredSize: preferredSize:
widget.width != null && widget.height != null widget.width != null && widget.height != null
? Size(widget.width!, widget.height!) ? Size(widget.width!, widget.height!)
@ -176,4 +185,54 @@ class _SCNetworkSvgaWidgetState extends State<SCNetworkSvgaWidget>
return widget.fallback ?? return widget.fallback ??
SizedBox(width: widget.width, height: widget.height); 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; int get currentFrame => controller.currentFrame;
MovieEntity get videoItem => controller.videoItem!; MovieEntity get videoItem => controller.videoItem!;
final FilterQuality filterQuality; final FilterQuality filterQuality;
final Rect? centerSlice;
final Rect? ninePatchSourceRect;
/// Guaranteed to draw within the canvas bounds /// Guaranteed to draw within the canvas bounds
final bool clipRect; final bool clipRect;
@ -13,6 +15,8 @@ class _SVGAPainter extends CustomPainter {
this.controller, { this.controller, {
this.fit = BoxFit.contain, this.fit = BoxFit.contain,
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
this.centerSlice,
this.ninePatchSourceRect,
this.clipRect = true, this.clipRect = true,
}) : assert( }) : assert(
controller.videoItem != null, 'Invalid SVGAAnimationController!'), controller.videoItem != null, 'Invalid SVGAAnimationController!'),
@ -33,8 +37,145 @@ class _SVGAPainter extends CustomPainter {
try { try {
final canvasRect = Offset.zero & size; final canvasRect = Offset.zero & size;
if (clipRect) canvas.clipRect(canvasRect); if (clipRect) canvas.clipRect(canvasRect);
scaleCanvasToViewBox(canvas, canvasRect, Offset.zero & viewBoxSize); final slice = centerSlice;
drawSprites(canvas, size); 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 { } finally {
canvas.restore(); canvas.restore();
} }

View File

@ -36,6 +36,17 @@ class SVGAImage extends StatefulWidget {
/// ///
/// Defaults to null. /// Defaults to null.
final Size? preferredSize; 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( const SVGAImage(
this._controller, { this._controller, {
super.key, super.key,
@ -44,6 +55,8 @@ class SVGAImage extends StatefulWidget {
this.allowDrawingOverflow, this.allowDrawingOverflow,
this.clearsAfterStop = true, this.clearsAfterStop = true,
this.preferredSize, this.preferredSize,
this.centerSlice,
this.ninePatchSourceRect,
}); });
@override @override
@ -285,6 +298,8 @@ class _SVGAImageState extends State<SVGAImage> {
widget._controller, widget._controller,
fit: widget.fit, fit: widget.fit,
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
centerSlice: widget.centerSlice,
ninePatchSourceRect: widget.ninePatchSourceRect,
// default is allowing overflow for backward compatibility // default is allowing overflow for backward compatibility
clipRect: widget.allowDrawingOverflow == false, 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 详情页补齐礼物名称/金额解析链路:礼物墙接口返回后会再用全局 `/gift/list` 配置表按 `standardId / id / giftCode / giftPhoto` 做二次匹配,补齐缺失的 `giftName / giftCandy / giftIntegral / giftPhoto`,用于单个礼物卡片和 `Total Value` 统计。
- 第十一步已补齐 Gift Wall 礼物动效预览:礼物卡点击时如果礼物 `giftSourceUrl``.svga`,会在当前页面全屏播放一次 SVGA 动效;播放期间使用页面状态锁防止重复叠加,播放完成或加载失败后自动清理 overlay 状态。 - 第十一步已补齐 Gift Wall 礼物动效预览:礼物卡点击时如果礼物 `giftSourceUrl``.svga`,会在当前页面全屏播放一次 SVGA 动效;播放期间使用页面状态锁防止重复叠加,播放完成或加载失败后自动清理 overlay 状态。
- 第十二步已补 Wall Of Honors 独立页面骨架:个人主页 `Wall Of Honors` 标题点击进入 `/main/person/wallOfHonors`;新页面先按无数据状态只展示满宽顶部返回栏、`My Achievements(0)` 标题、`Honors(0) / Event(0)` 两个 tab 和底部空态文案,顶部栏沿用满宽 `Stack` 写法避免返回箭头挤压标题。 - 第十二步已补 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 语言房音乐功能(进行中)
- 已查阅桌面《Android音乐添加流程.md》并产出可执行方案文档 `docs/android-room-music-execution-plan.md`覆盖功能范围、模块拆分、Android 权限、声网混音链路、UI 流程、风险点和 GPT 5.5 预估开发工时。 - 已查阅桌面《Android音乐添加流程.md》并产出可执行方案文档 `docs/android-room-music-execution-plan.md`覆盖功能范围、模块拆分、Android 权限、声网混音链路、UI 流程、风险点和 GPT 5.5 预估开发工时。
@ -546,6 +547,9 @@
- 已修正红包历史页和领取记录页的 RTL 排版:列表主文本列改回 `start` 对齐以跟随 RTL 靠右,金额列跟随 `end` 靠外侧;金额和时间强制按 LTR 渲染,避免阿语环境下时间顺序错乱或负号跑到数字后面。 - 已修正红包历史页和领取记录页的 RTL 排版:列表主文本列改回 `start` 对齐以跟随 RTL 靠右,金额列跟随 `end` 靠外侧;金额和时间强制按 LTR 渲染,避免阿语环境下时间顺序错乱或负号跑到数字后面。
- 已按最新接口文档接入红包领取记录新接口:领取记录页优先调用 `GET /go/app/voice-room/red-packet/claim-records?packetNo=...` 拉取领取人列表,失败时再兜底旧 detail records同时历史页和领取记录页跳转改为深色无默认转场 route避免进入页面时先闪一下白屏。 - 已按最新接口文档接入红包领取记录新接口:领取记录页优先调用 `GET /go/app/voice-room/red-packet/claim-records?packetNo=...` 拉取领取人列表,失败时再兜底旧 detail records同时历史页和领取记录页跳转改为深色无默认转场 route避免进入页面时先闪一下白屏。
- 已将红包规则页替换为正式规则文案,并同步更新 `en/ar/bn/tr` 多语言;第 4 条每日房间红包发送上限不再写死,进入规则页时调用红包配置接口读取 `dailySendLimit` 后填入 `%1$s` 占位。 - 已将红包规则页替换为正式规则文案,并同步更新 `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 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。 - 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji``monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。