diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index 59dfe7f..280deab 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -577,6 +577,8 @@ "resetsDailyAtMidnight": "يُعاد التعيين يوميًا عند منتصف الليل", "limitedToOneTime": "لمرة واحدة فقط", "useMicrophoneForOneMin": "استخدم الميكروفون لمدة دقيقة واحدة", + "taskRequirement": "المتطلبات", + "taskProgress": "التقدم", "rewardClaimedSuccessfully": "تم استلام المكافأة بنجاح", "rewardClaimFailed": "فشل استلام المكافأة", "nickName": "الاسم المستعار", diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index ffe09bc..787bf0f 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -513,6 +513,8 @@ "resetsDailyAtMidnight": "প্রতিদিন মধ্যরাতে রিসেট হয়", "limitedToOneTime": "শুধু একবারের জন্য", "useMicrophoneForOneMin": "১ মিনিট মাইক্রোফোন ব্যবহার করুন", + "taskRequirement": "প্রয়োজন", + "taskProgress": "অগ্রগতি", "rewardClaimedSuccessfully": "পুরস্কার সফলভাবে গ্রহণ করা হয়েছে", "rewardClaimFailed": "পুরস্কার গ্রহণ ব্যর্থ হয়েছে", "enterRoomConfirmTips": "আপনি কি রুমে যেতে চান?", diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index 9408c84..3373725 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -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?", diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index 4ad6536..1b307e8 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -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?", diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart index 4bdfd3b..1637e24 100644 --- a/lib/app_localizations.dart +++ b/lib/app_localizations.dart @@ -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'); diff --git a/lib/modules/chat/message_chat_page.dart b/lib/modules/chat/message_chat_page.dart index 3e0aba8..7df9e7a 100644 --- a/lib/modules/chat/message_chat_page.dart +++ b/lib/modules/chat/message_chat_page.dart @@ -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 = [ + 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 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 = [ 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, ]); } diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index 9ec06c3..081879d 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -51,6 +51,7 @@ class _PersonDetailPageState extends State { 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 { .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 { ), SizedBox(height: 12.w), _buildWallGrid( - itemCount: 4, + itemCount: _wallPreviewItemCount, itemBuilder: (_) => _buildHonorPlaceholderItem(), ), SizedBox(height: 18.w), @@ -659,7 +660,10 @@ class _PersonDetailPageState extends State { ), 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; diff --git a/lib/modules/user/task/task_page.dart b/lib/modules/user/task/task_page.dart index 440de2c..3cd0569 100644 --- a/lib/modules/user/task/task_page.dart +++ b/lib/modules/user/task/task_page.dart @@ -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 createState() => _TaskPageState(); } -class _TaskPageState extends State { +class _TaskPageState extends State + 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 { final Set _claimingTaskIds = {}; List _tasks = []; bool _loading = true; + bool _loadingTasks = false; String? _toastMessage; Timer? _toastTimer; + PageRoute? _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 && _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 _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 { _tasks = []; _loading = false; }); + } finally { + _loadingTasks = false; } } @@ -255,13 +296,13 @@ class _TaskPageState extends State { _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 { 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 { Future _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 { } } - Future _openJumpPage(_TaskUiItem task) async { + Future _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 { '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 { jumpType: jumpType, jumpUrl: target, ); + return true; } Future _firstRoomJumpUrl(String target) async { @@ -393,19 +452,7 @@ class _TaskPageState extends State { 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 { 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 { 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 { 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 { 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 tasks) { @@ -502,6 +675,7 @@ class _TaskPageState extends State { '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, ); } diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index ed76c3b..bc25fe5 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -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 _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, + ); + } } diff --git a/lib/shared/business_logic/models/res/sc_task_list_res.dart b/lib/shared/business_logic/models/res/sc_task_list_res.dart index f94898d..ea2e090 100644 --- a/lib/shared/business_logic/models/res/sc_task_list_res.dart +++ b/lib/shared/business_logic/models/res/sc_task_list_res.dart @@ -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 values) { + for (final value in values) { + final number = _asNum(value); + if (number != null) { + return number; + } + } return null; } } diff --git a/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart b/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart index 4ded020..6cd5300 100644 --- a/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart +++ b/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart @@ -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"; diff --git a/lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart b/lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart index 75f87de..23a85d4 100644 --- a/lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart +++ b/lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart @@ -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 = diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_claim_record_page.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_claim_record_page.dart index 7f29952..2fe60da 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_claim_record_page.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_claim_record_page.dart @@ -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'; diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart index a8a96f3..2daea98 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart @@ -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 { 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 { ); } + 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( + 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( + 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'; diff --git a/lib/ui_kit/widgets/room/room_menu_dialog.dart b/lib/ui_kit/widgets/room/room_menu_dialog.dart index 86c58c2..8760d88 100644 --- a/lib/ui_kit/widgets/room/room_menu_dialog.dart +++ b/lib/ui_kit/widgets/room/room_menu_dialog.dart @@ -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 { 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 items2 = []; @@ -96,13 +96,13 @@ class _RoomMenuDialogState extends State { "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(context, listen: false).isFz()) { items2.add( RoomMenu( @@ -272,9 +272,9 @@ class _RoomMenuDialogState extends State { 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 { } 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 { 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, ), ); } diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index 8c5670b..ff74391 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -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 { 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 { width: width, height: height, fit: BoxFit.fill, + centerSliceRatio: _vipBubbleCenterSliceRatio, + sourceEdgeInset: _vipBubbleSourceEdgeInset, fallback: fallback, ); } @@ -463,22 +475,13 @@ class _MsgItemState extends State { 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 { } String _resolveRoomChatBubbleImageUrl(PropsResources? resource) { - return _firstNonBlank([ + final candidates = [ resource?.roomSendCoverUrl, resource?.roomOpenedUrl, resource?.roomNotOpenedUrl, @@ -506,6 +509,10 @@ class _MsgItemState extends State { resource?.cover, resource?.expand, resource?.sourceUrl, + ]; + return _firstNonBlank([ + ...candidates.where((value) => SCNetworkSvgaWidget.isSvga(value)), + ...candidates, ]); } diff --git a/lib/ui_kit/widgets/room/room_play_widget.dart b/lib/ui_kit/widgets/room/room_play_widget.dart index b61317d..28c151d 100644 --- a/lib/ui_kit/widgets/room/room_play_widget.dart +++ b/lib/ui_kit/widgets/room/room_play_widget.dart @@ -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( diff --git a/lib/ui_kit/widgets/svga/sc_network_svga_widget.dart b/lib/ui_kit/widgets/svga/sc_network_svga_widget.dart index bc164eb..c4e53ef 100644 --- a/lib/ui_kit/widgets/svga/sc_network_svga_widget.dart +++ b/lib/ui_kit/widgets/svga/sc_network_svga_widget.dart @@ -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 @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 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 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; + } } diff --git a/local_packages/flutter_svga-0.0.13-patched/lib/src/painter.dart b/local_packages/flutter_svga-0.0.13-patched/lib/src/painter.dart index f5be380..05aa57d 100644 --- a/local_packages/flutter_svga-0.0.13-patched/lib/src/painter.dart +++ b/local_packages/flutter_svga-0.0.13-patched/lib/src/painter.dart @@ -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 = [ + sourceRect.left, + centerSlice.left, + centerSlice.right, + sourceRect.right, + ]; + final sourceYs = [ + sourceRect.top, + centerSlice.top, + centerSlice.bottom, + sourceRect.bottom, + ]; + final destXs = [ + canvasRect.left, + canvasRect.left + dstLeft, + canvasRect.right - dstRight, + canvasRect.right, + ]; + final destYs = [ + 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(); } diff --git a/local_packages/flutter_svga-0.0.13-patched/lib/src/player.dart b/local_packages/flutter_svga-0.0.13-patched/lib/src/player.dart index 39241ec..337ce7e 100644 --- a/local_packages/flutter_svga-0.0.13-patched/lib/src/player.dart +++ b/local_packages/flutter_svga-0.0.13-patched/lib/src/player.dart @@ -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 { 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, ), diff --git a/需求进度.md b/需求进度.md index 0923a0e..cd71e4c 100644 --- a/需求进度.md +++ b/需求进度.md @@ -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 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。