diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index bf375c9..48ac919 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -723,5 +723,8 @@ "duration2": "المدة:{1}", "roomRocketNoteTitle": "ملاحظة", "roomRocketNoteSectionTitle": "في كل مرة يتم فيها شراء مستوى VIP", - "roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية." + "roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية.", + "roomRocketRewardEmptyText": "لم تحصل على مكافآت. حظًا أوفر في المرة القادمة.", + "roomRocketRecordEmptyText": "لا توجد سجلات مكافآت", + "launchRocket": "إطلاق الصاروخ" } diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index cfb2ca5..a7cbbf2 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -723,5 +723,8 @@ "duration2": "সময়:{1}", "roomRocketNoteTitle": "নোট", "roomRocketNoteSectionTitle": "প্রতিবার VIP লেভেল কেনার সময়", - "roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।" + "roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।", + "roomRocketRewardEmptyText": "কোনো পুরস্কার পাওয়া যায়নি। পরের বার শুভকামনা।", + "roomRocketRecordEmptyText": "কোনো পুরস্কারের রেকর্ড নেই", + "launchRocket": "রকেট চালু করুন" } diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index c0bdebc..8913d5f 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -723,5 +723,8 @@ "duration2": "Duration:{1}", "roomRocketNoteTitle": "Note", "roomRocketNoteSectionTitle": "Each time a VIP", - "roomRocketNoteSectionBody": "1. Each time a VIP level is purchased, it is valid for 30 days, and can be manually renewed after expiration." + "roomRocketNoteSectionBody": "1. Each time a VIP level is purchased, it is valid for 30 days, and can be manually renewed after expiration.", + "roomRocketRewardEmptyText": "No rewards earned. Better luck next time.", + "roomRocketRecordEmptyText": "No reward records", + "launchRocket": "Launch Rocket" } diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index 5d5cd39..7715660 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -723,5 +723,8 @@ "duration2": "Süre:{1}", "roomRocketNoteTitle": "Not", "roomRocketNoteSectionTitle": "Her VIP seviyesi satın alındığında", - "roomRocketNoteSectionBody": "1. Her VIP seviyesi satın alındığında 30 gün geçerlidir ve süresi dolduktan sonra manuel olarak yenilenebilir." + "roomRocketNoteSectionBody": "1. Her VIP seviyesi satın alındığında 30 gün geçerlidir ve süresi dolduktan sonra manuel olarak yenilenebilir.", + "roomRocketRewardEmptyText": "Ödül kazanmadın. Bir dahaki sefere bol şans.", + "roomRocketRecordEmptyText": "Ödül kaydı yok", + "launchRocket": "Roketi Fırlat" } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cdaa4c8..f371098 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -189,6 +189,7 @@ PODS: - FlutterMacOS - iris_method_channel (0.0.1): - Flutter + - libpag (4.5.52) - libwebp (1.5.0): - libwebp/demux (= 1.5.0) - libwebp/mux (= 1.5.0) @@ -214,6 +215,9 @@ PODS: - SwiftyBeaver - package_info_plus (0.4.5): - Flutter + - pag (0.0.1): + - Flutter + - libpag - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) @@ -275,6 +279,7 @@ DEPENDENCIES: - iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`) - on_audio_query_ios (from `.symlinks/plugins/on_audio_query_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - pag (from `.symlinks/plugins/pag/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) @@ -311,6 +316,7 @@ SPEC REPOS: - GTMAppAuth - GTMSessionFetcher - HydraAsync + - libpag - libwebp - Mantle - nanopb @@ -358,6 +364,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/on_audio_query_ios/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + pag: + :path: ".symlinks/plugins/pag/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: @@ -419,11 +427,13 @@ SPEC CHECKSUMS: image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 iris_method_channel: 7d661cf3259b3009ae423508470dbeb9374446ee + libpag: 98742fad4b3ac2a2ee31e383d2483495fb9a5fb4 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 on_audio_query_ios: 28a780e2d0d85d92d500ba6e12c6c8167022b2fa package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + pag: f01aa9017ab0e04a83ba4a20d6070a50f0ac9da8 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 49d0ef2..adc7601 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -502,7 +502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 131; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = S9X2AJ2US9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -693,7 +693,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 131; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -722,7 +722,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 131; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -731,7 +731,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart index ad694ee..8f6f70b 100644 --- a/lib/app_localizations.dart +++ b/lib/app_localizations.dart @@ -799,6 +799,8 @@ class SCAppLocalizations { String get launchedARocket => translate('launchedARocket'); + String get launchRocket => translate('launchRocket'); + String get useCoupontips => translate('useCoupontips'); String get joinMemberTips2 => translate('joinMemberTips2'); diff --git a/lib/debug/room_rocket_floating_preview.dart b/lib/debug/room_rocket_floating_preview.dart new file mode 100644 index 0000000..3380d46 --- /dev/null +++ b/lib/debug/room_rocket_floating_preview.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; +import 'package:yumi/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart'; + +void main() { + runApp(const RoomRocketFloatingPreviewApp()); +} + +class RoomRocketFloatingPreviewApp extends StatelessWidget { + const RoomRocketFloatingPreviewApp({super.key}); + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + designSize: const Size(375, 812), + minTextAdapt: true, + builder: (_, child) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: const _RoomRocketFloatingPreviewPage(), + ); + }, + ); + } +} + +class _RoomRocketFloatingPreviewPage extends StatelessWidget { + const _RoomRocketFloatingPreviewPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF20100B), Color(0xFF5A2A13)], + ), + ), + ), + ), + Transform.translate( + offset: Offset(0, 70.w), + child: FloatingRoomRocketScreenWidget( + message: SCFloatingMessage( + type: 3, + roomId: '123456', + userName: 'Namenamna...', + userAvatarUrl: + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330', + durationSeconds: 20, + ), + onAnimationCompleted: () {}, + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index b57ad25..49dab82 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -60,10 +60,12 @@ class GiftPage extends StatefulWidget { } class _GiftPageState extends State with TickerProviderStateMixin { + static const String _backpackGiftTab = SCAppGeneralManager.backpackGiftTab; static const Duration _comboSendBatchWindow = Duration(milliseconds: 200); static const List _preferredGiftTabOrder = [ "ALL", + _backpackGiftTab, "ACTIVITY", "LUCKY_GIFT", "CP", @@ -218,7 +220,12 @@ class _GiftPageState extends State with TickerProviderStateMixin { listen: false, ).giftList(includeCustomized: false); Provider.of(context, listen: false).giftActivityList(); - // Provider.of(context, listen: false).giftBackpack(); + unawaited( + Provider.of( + context, + listen: false, + ).giftBackpack(forceRefresh: true), + ); Provider.of(context, listen: false).balance(); rtcProvider?.roomWheatMap.forEach((k, v) { if (v.user != null) { @@ -262,10 +269,23 @@ class _GiftPageState extends State with TickerProviderStateMixin { ref.giftByTab.entries .where((entry) => entry.value.isNotEmpty) .where((entry) => entry.key != "CUSTOMIZED") + .where((entry) => entry.key != _backpackGiftTab) .map((entry) => entry.key) .toList(); final orderedTypes = []; + if (availableTypes.remove("ALL") || + ref.isGiftListLoading || + ref.giftResList.isEmpty) { + orderedTypes.add("ALL"); + } for (final type in _preferredGiftTabOrder) { + if (type == "ALL") { + continue; + } + if (type == _backpackGiftTab) { + orderedTypes.add(type); + continue; + } if (availableTypes.remove(type)) { orderedTypes.add(type); } @@ -328,7 +348,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { void _ensureCurrentTabSelection(SCAppGeneralManager ref, String tabType) { final gifts = ref.giftByTab[tabType] ?? const []; - if (_matchesCurrentGift(gifts)) { + if (_matchesCurrentGift(tabType, gifts)) { return; } final gift = _resolveSelectedGift(tabType, gifts); @@ -347,6 +367,13 @@ class _GiftPageState extends State with TickerProviderStateMixin { final selectedGift = _selectedGiftByTab[tabType]; if (selectedGift?.id != null) { for (final gift in gifts) { + if (tabType == _backpackGiftTab) { + final selectedBackpackId = selectedGift!.backpackId; + if ((selectedBackpackId ?? "").isNotEmpty && + gift.backpackId == selectedBackpackId) { + return gift; + } + } if (gift.id == selectedGift!.id) { return gift; } @@ -359,12 +386,22 @@ class _GiftPageState extends State with TickerProviderStateMixin { return gifts.first; } - bool _matchesCurrentGift(List gifts) { + bool _matchesCurrentGift(String tabType, List gifts) { final currentGiftId = checkedGift?.id; if (currentGiftId == null) { return gifts.isEmpty; } - return gifts.any((gift) => gift.id == currentGiftId); + for (final gift in gifts) { + if (gift.id != currentGiftId) { + continue; + } + if (tabType != _backpackGiftTab) { + return true; + } + return gift.backpackId == checkedGift?.backpackId && + gift.backpackQuantity == checkedGift?.backpackQuantity; + } + return false; } String _giftTabLabel(SCAppLocalizations localizations, String tabType) { @@ -384,6 +421,8 @@ class _GiftPageState extends State with TickerProviderStateMixin { return localizations.customized; case "NSCIONAL_FLAG": return localizations.country; + case _backpackGiftTab: + return localizations.bag; default: return tabType .toLowerCase() @@ -421,6 +460,10 @@ class _GiftPageState extends State with TickerProviderStateMixin { giftTab == SCGiftType.MAGIC.name; } + bool _usesBackpackGiftEndpoint(SocialChatGiftRes? gift) { + return (gift?.giftTab ?? '').trim().toLowerCase() == _backpackGiftTab; + } + bool _hasValidLuckyGiftStandardId(SocialChatGiftRes gift) { final standardId = (gift.standardId ?? '').trim(); return standardId.isNotEmpty && standardId != '0'; @@ -444,7 +487,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { if (error is DioException) { final responseData = error.response?.data; - if (responseData is Map) { + if (responseData is Map) { final responseMessage = sanitize( responseData['errorMsg']?.toString() ?? '', ); @@ -488,6 +531,47 @@ class _GiftPageState extends State with TickerProviderStateMixin { return 'Gift sending failed, please try again.'; } + String _backpackQuantityInsufficientMessage() { + return 'Gift quantity is insufficient or has been used up.'; + } + + bool _isBackpackQuantityError(Object error) { + final errorText = _readBackendErrorText(error).toUpperCase(); + return errorText.contains('INSUFFICIENT_BALANCE'); + } + + String _readBackendErrorText(Object error) { + final values = []; + void addValue(dynamic value) { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty) { + values.add(text); + } + } + + if (error is DioException) { + final responseData = error.response?.data; + if (responseData is Map) { + addValue(responseData['errorCodeName']); + addValue(responseData['codeName']); + addValue(responseData['errorCode']); + addValue(responseData['code']); + addValue(responseData['errorMsg']); + addValue(responseData['message']); + } else { + addValue(responseData); + } + addValue(error.message); + addValue(error.error); + } else if (error is NotSuccessException) { + addValue(error.message); + } else { + addValue(error); + } + + return values.join(' '); + } + @override Widget build(BuildContext context) { return Consumer( @@ -1108,7 +1192,51 @@ class _GiftPageState extends State with TickerProviderStateMixin { final roomId = rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id ?? ""; final roomAccount = rtcProvider?.currenRoom?.roomProfile?.roomProfile?.roomAccount ?? ""; - final isLuckyGiftRequest = _usesLuckyGiftEndpoint(selectedGift); + final isBackpackGiftRequest = _usesBackpackGiftEndpoint(selectedGift); + final isLuckyGiftRequest = + !isBackpackGiftRequest && _usesLuckyGiftEndpoint(selectedGift); + + if (isBackpackGiftRequest) { + final backpackRecordId = (selectedGift.backpackId ?? "").trim(); + final giftId = (selectedGift.id ?? "").trim(); + if (backpackRecordId.isEmpty || giftId.isEmpty) { + const configError = + 'Gift configuration unavailable, please try another gift.'; + _giftFxLog( + 'giveBackpackGift skipped giftId=${selectedGift.id} ' + 'giftName=${selectedGift.giftName} ' + 'backpackId=${selectedGift.backpackId} ' + 'reason=invalid_backpack_config', + ); + SCTts.show(configError); + return null; + } + final availableQuantity = + selectedGift.backpackQuantity?.toInt() ?? + int.tryParse(selectedGift.quantity ?? '') ?? + 0; + final requiredQuantity = selectedNumber * acceptUserIds.length; + if (requiredQuantity > availableQuantity) { + _giftFxLog( + 'giveBackpackGift skipped giftId=${selectedGift.id} ' + 'giftName=${selectedGift.giftName} ' + 'backpackId=${selectedGift.backpackId} ' + 'selectedNumber=$selectedNumber ' + 'acceptCount=${acceptUserIds.length} ' + 'requiredQuantity=$requiredQuantity ' + 'availableQuantity=$availableQuantity ' + 'reason=insufficient_backpack_quantity', + ); + SCTts.show(_backpackQuantityInsufficientMessage()); + unawaited( + Provider.of( + context, + listen: false, + ).giftBackpack(forceRefresh: true), + ); + return null; + } + } if (isLuckyGiftRequest && !_hasValidLuckyGiftStandardId(selectedGift)) { const configError = @@ -1134,6 +1262,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { roomId: roomId, roomAccount: roomAccount, isLuckyGiftRequest: isLuckyGiftRequest, + isBackpackGiftRequest: isBackpackGiftRequest, ); } @@ -1144,19 +1273,23 @@ class _GiftPageState extends State with TickerProviderStateMixin { return; } - RoomGiftComboSendController().show( - request: RoomGiftComboSendRequest( - acceptUserIds: List.from(request.acceptUserIds), - acceptUsers: List.from(request.acceptUsers), - gift: request.gift, - quantity: request.quantity, - clickCount: request.clickCount, - roomId: request.roomId, - roomAccount: request.roomAccount, - isLuckyGiftRequest: request.isLuckyGiftRequest, - ), - executor: _performFloatingComboSend, - ); + if (request.isBackpackGiftRequest) { + RoomGiftComboSendController().hide(); + } else { + RoomGiftComboSendController().show( + request: RoomGiftComboSendRequest( + acceptUserIds: List.from(request.acceptUserIds), + acceptUsers: List.from(request.acceptUsers), + gift: request.gift, + quantity: request.quantity, + clickCount: request.clickCount, + roomId: request.roomId, + roomAccount: request.roomAccount, + isLuckyGiftRequest: request.isLuckyGiftRequest, + ), + executor: _performFloatingComboSend, + ); + } if (_supportsComboRequestBatching(request.gift)) { _enqueueComboGiftSendRequest(request); @@ -1168,7 +1301,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { } bool _supportsComboRequestBatching(SocialChatGiftRes gift) { - return _supportsComboFeedback(gift); + return !_usesBackpackGiftEndpoint(gift) && _supportsComboFeedback(gift); } void _enqueueComboGiftSendRequest(_GiftSendRequest request) { @@ -1315,6 +1448,40 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'quantity=${request.quantity} ' 'clickCount=${request.clickCount}', ); + if (request.isBackpackGiftRequest) { + final sentUsers = await _giveBackpackGiftToRecipients( + repository, + request, + ); + _giftFxLog( + '$requestName success trigger=$trigger ' + 'giftId=${request.gift.id} ' + 'giftName=${request.gift.giftName} ' + 'giftSourceUrl=${request.gift.giftSourceUrl} ' + 'special=${request.gift.special} ' + 'giftTab=${request.gift.giftTab} ' + 'backpackId=${request.gift.backpackId} ' + 'backpackQuantity=${request.gift.backpackQuantity} ' + 'number=${request.quantity} ' + 'clickCount=${request.clickCount} ' + 'acceptUserIds=${request.acceptUserIds.join(",")} ' + 'roomId=${request.roomId} ' + 'elapsedMs=${stopwatch.elapsedMilliseconds}', + ); + if (sentUsers.isNotEmpty) { + final giftBatchId = _createGiftBatchId(request); + sendGiftMsg( + sentUsers, + gift: request.gift, + quantity: request.quantity, + giftBatchId: giftBatchId, + animationCount: request.clickCount, + ); + } + await _refreshGiftBackpack(); + return; + } + final result = request.isLuckyGiftRequest ? await repository.giveLuckyGift( @@ -1374,7 +1541,10 @@ class _GiftPageState extends State with TickerProviderStateMixin { } profileManager?.updateBalance(result); } catch (e) { - final errorMessage = _resolveGiftSendErrorMessage(e); + final errorMessage = + request.isBackpackGiftRequest && _isBackpackQuantityError(e) + ? _backpackQuantityInsufficientMessage() + : _resolveGiftSendErrorMessage(e); final errorDetails = _describeGiftSendError(e); _giftFxLog( '$requestName failed trigger=$trigger ' @@ -1387,10 +1557,79 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'details={$errorDetails} ' 'elapsedMs=${stopwatch.elapsedMilliseconds}', ); + if (request.isBackpackGiftRequest) { + unawaited(_refreshGiftBackpack()); + } SCTts.show(errorMessage); } } + Future> _giveBackpackGiftToRecipients( + SCChatRoomRepository repository, + _GiftSendRequest request, + ) async { + final sentUsers = []; + for (var i = 0; i < request.acceptUserIds.length; i++) { + final acceptUserId = request.acceptUserIds[i].trim(); + if (acceptUserId.isEmpty) { + continue; + } + final acceptUser = + i < request.acceptUsers.length ? request.acceptUsers[i] : null; + try { + await repository.giveBackpackGift( + id: request.gift.backpackId ?? "", + acceptUserId: acceptUserId, + giftId: request.gift.id ?? "", + quantity: request.quantity, + roomId: request.roomId, + seatIndex: + acceptUser?.micIndex ?? _resolveSeatIndexForUserId(acceptUserId), + ); + if (acceptUser != null) { + sentUsers.add(acceptUser); + } + } catch (_) { + if (sentUsers.isNotEmpty) { + final giftBatchId = _createGiftBatchId(request); + sendGiftMsg( + sentUsers, + gift: request.gift, + quantity: request.quantity, + giftBatchId: giftBatchId, + animationCount: request.clickCount, + ); + } + rethrow; + } + } + return sentUsers; + } + + num? _resolveSeatIndexForUserId(String userId) { + final normalizedUserId = userId.trim(); + if (normalizedUserId.isEmpty) { + return null; + } + for (final mic in rtcProvider?.roomWheatMap.values ?? const []) { + if ((mic.user?.id ?? "").trim() == normalizedUserId) { + return mic.micIndex; + } + } + return null; + } + + Future _refreshGiftBackpack() async { + final context = navigatorKey.currentState?.context; + if (context == null) { + return; + } + await Provider.of( + context, + listen: false, + ).giftBackpack(forceRefresh: true); + } + void sendGiftMsg( List acceptUsers, { required SocialChatGiftRes gift, @@ -1628,6 +1867,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { roomId: request.roomId, roomAccount: request.roomAccount, isLuckyGiftRequest: request.isLuckyGiftRequest, + isBackpackGiftRequest: _usesBackpackGiftEndpoint(request.gift), ), trigger: trigger, ); @@ -1752,7 +1992,11 @@ class _GiftPageState extends State with TickerProviderStateMixin { final sortedAcceptUserIds = List.from(request.acceptUserIds) ..sort(); return [ - request.isLuckyGiftRequest ? 'lucky' : 'gift', + request.isBackpackGiftRequest + ? 'backpack' + : request.isLuckyGiftRequest + ? 'lucky' + : 'gift', request.roomId, request.gift.id ?? '', request.quantity, @@ -1842,6 +2086,7 @@ class _GiftSendRequest { required this.roomId, required this.roomAccount, required this.isLuckyGiftRequest, + required this.isBackpackGiftRequest, }); final List acceptUserIds; @@ -1852,15 +2097,30 @@ class _GiftSendRequest { final String roomId; final String roomAccount; final bool isLuckyGiftRequest; + final bool isBackpackGiftRequest; - String get requestName => isLuckyGiftRequest ? 'giveLuckyGift' : 'giveGift'; + String get requestName { + if (isBackpackGiftRequest) { + return 'giveBackpackGift'; + } + return isLuckyGiftRequest ? 'giveLuckyGift' : 'giveGift'; + } String get batchKey { final sortedAcceptUserIds = List.from(acceptUserIds)..sort(); - return '${isLuckyGiftRequest ? "lucky" : "gift"}' - '|${gift.id ?? ""}' - '|$roomId' - '|${sortedAcceptUserIds.join(",")}'; + final type = + isBackpackGiftRequest + ? "backpack" + : isLuckyGiftRequest + ? "lucky" + : "gift"; + return [ + type, + gift.id ?? "", + gift.backpackId ?? "", + roomId, + sortedAcceptUserIds.join(","), + ].join("|"); } } @@ -1875,6 +2135,7 @@ class _PendingGiftSendBatch { required this.roomId, required this.roomAccount, required this.isLuckyGiftRequest, + required this.isBackpackGiftRequest, required this.readyAt, }); @@ -1892,6 +2153,7 @@ class _PendingGiftSendBatch { roomId: request.roomId, roomAccount: request.roomAccount, isLuckyGiftRequest: request.isLuckyGiftRequest, + isBackpackGiftRequest: request.isBackpackGiftRequest, readyAt: readyAt, ); } @@ -1905,6 +2167,7 @@ class _PendingGiftSendBatch { final String roomId; final String roomAccount; final bool isLuckyGiftRequest; + final bool isBackpackGiftRequest; DateTime readyAt; _GiftSendRequest toRequest() { @@ -1917,6 +2180,7 @@ class _PendingGiftSendBatch { roomId: roomId, roomAccount: roomAccount, isLuckyGiftRequest: isLuckyGiftRequest, + isBackpackGiftRequest: isBackpackGiftRequest, ); } } diff --git a/lib/modules/gift/gift_tab_page.dart b/lib/modules/gift/gift_tab_page.dart index 574c267..7dff45e 100644 --- a/lib/modules/gift/gift_tab_page.dart +++ b/lib/modules/gift/gift_tab_page.dart @@ -64,7 +64,7 @@ class _GiftTabPageState extends State _schedulePageWarmup(ref, gifts, _index); } return gifts.isEmpty - ? (ref.isGiftListLoading + ? (ref.isGiftTabLoading(widget.type) ? _buildGiftPageSkeleton() : mainEmpty( textColor: Colors.white54, @@ -444,19 +444,33 @@ class _GiftTabPageState extends State ), Row( mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - _strategy.getGiftPageGoldCoinIcon(), - width: 14.w, - height: 14.w, - ), - SizedBox(width: 3.w), - text( - "${gift.giftCandy}", - fontSize: 10.sp, - textColor: widget.isDark ? Colors.white : Colors.black, - ), - ], + children: + _isBackpackTab + ? [ + text( + "x${_formatBackpackQuantity(gift)}", + fontSize: 10.sp, + fontWeight: FontWeight.w600, + textColor: + widget.isDark + ? SocialChatTheme.primaryLight + : SocialChatTheme.primaryColor, + ), + ] + : [ + Image.asset( + _strategy.getGiftPageGoldCoinIcon(), + width: 14.w, + height: 14.w, + ), + SizedBox(width: 3.w), + text( + "${gift.giftCandy}", + fontSize: 10.sp, + textColor: + widget.isDark ? Colors.white : Colors.black, + ), + ], ), ], ), @@ -470,6 +484,19 @@ class _GiftTabPageState extends State ); } + bool get _isBackpackTab => widget.type == SCAppGeneralManager.backpackGiftTab; + + String _formatBackpackQuantity(SocialChatGiftRes gift) { + final quantity = gift.backpackQuantity; + if (quantity != null) { + if (quantity % 1 == 0) { + return quantity.toInt().toString(); + } + return quantity.toString(); + } + return gift.quantity ?? '0'; + } + _indicator(SCAppGeneralManager ref) { var size = (ref.giftByTab[widget.type]!.length) / 8; var list = []; diff --git a/lib/modules/home/popular/follow/sc_room_follow_page.dart b/lib/modules/home/popular/follow/sc_room_follow_page.dart index 5962815..8f4e4c1 100644 --- a/lib/modules/home/popular/follow/sc_room_follow_page.dart +++ b/lib/modules/home/popular/follow/sc_room_follow_page.dart @@ -203,35 +203,17 @@ class _RoomFollowPageState ), ), SizedBox(width: 5.w), - (roomRes - .roomProfile - ?.extValues - ?.roomSetting - ?.password - ?.isEmpty ?? - false) + roomRes.roomProfile?.extValues?.existsPassword != true ? SCRoomLiveAudioIndicator(width: 14.w, height: 14.w) : Image.asset( "sc_images/index/sc_icon_room_suo.png", width: 20.w, height: 20.w, ), - (roomRes - .roomProfile - ?.extValues - ?.roomSetting - ?.password - ?.isEmpty ?? - false) + roomRes.roomProfile?.extValues?.existsPassword != true ? SizedBox(width: 3.w) : Container(height: 10.w), - (roomRes - .roomProfile - ?.extValues - ?.roomSetting - ?.password - ?.isEmpty ?? - false) + roomRes.roomProfile?.extValues?.existsPassword != true ? text( roomRes.roomProfile?.displayMemberCount ?? "0", fontSize: 10.sp, @@ -363,8 +345,8 @@ class _RoomFollowPageState previous.roomProfile?.roomGameIcon == next.roomProfile?.roomGameIcon && previous.roomProfile?.displayMemberCount == next.roomProfile?.displayMemberCount && - previous.roomProfile?.extValues?.roomSetting?.password == - next.roomProfile?.extValues?.roomSetting?.password; + previous.roomProfile?.extValues?.existsPassword == + next.roomProfile?.extValues?.existsPassword; } bool _sameRoomLists(List previous, List next) { diff --git a/lib/modules/home/popular/history/sc_room_history_page.dart b/lib/modules/home/popular/history/sc_room_history_page.dart index 9842276..cebdea6 100644 --- a/lib/modules/home/popular/history/sc_room_history_page.dart +++ b/lib/modules/home/popular/history/sc_room_history_page.dart @@ -204,35 +204,17 @@ class _SCRoomHistoryPageState ), ), SizedBox(width: 5.w), - (roomRes - .roomProfile - ?.extValues - ?.roomSetting - ?.password - ?.isEmpty ?? - false) + roomRes.roomProfile?.extValues?.existsPassword != true ? SCRoomLiveAudioIndicator(width: 14.w, height: 14.w) : Image.asset( "sc_images/index/sc_icon_room_suo.png", width: 20.w, height: 20.w, ), - (roomRes - .roomProfile - ?.extValues - ?.roomSetting - ?.password - ?.isEmpty ?? - false) + roomRes.roomProfile?.extValues?.existsPassword != true ? SizedBox(width: 3.w) : Container(height: 10.w), - (roomRes - .roomProfile - ?.extValues - ?.roomSetting - ?.password - ?.isEmpty ?? - false) + roomRes.roomProfile?.extValues?.existsPassword != true ? text( roomRes.roomProfile?.displayMemberCount ?? "0", fontSize: 10.sp, @@ -363,8 +345,8 @@ class _SCRoomHistoryPageState previous.roomProfile?.roomGameIcon == next.roomProfile?.roomGameIcon && previous.roomProfile?.displayMemberCount == next.roomProfile?.displayMemberCount && - previous.roomProfile?.extValues?.roomSetting?.password == - next.roomProfile?.extValues?.roomSetting?.password; + previous.roomProfile?.extValues?.existsPassword == + next.roomProfile?.extValues?.existsPassword; } bool _sameRoomLists(List previous, List next) { diff --git a/lib/modules/home/popular/party/sc_home_party_page.dart b/lib/modules/home/popular/party/sc_home_party_page.dart index 92b28fb..69be8f6 100644 --- a/lib/modules/home/popular/party/sc_home_party_page.dart +++ b/lib/modules/home/popular/party/sc_home_party_page.dart @@ -3,16 +3,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; -import 'package:provider/provider.dart'; -import 'package:pull_to_refresh/pull_to_refresh.dart'; -import '../../../../app_localizations.dart'; -import '../../../../app/constants/sc_global_config.dart'; -import '../../../../app/routes/sc_fluro_navigator.dart'; -import '../../../../shared/business_logic/models/res/sc_index_banner_res.dart'; -import '../../../../shared/tools/sc_banner_utils.dart'; -import '../../../../shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; -import '../../../../shared/business_logic/models/res/follow_room_res.dart'; -import '../../../../shared/business_logic/models/res/room_res.dart'; +import 'package:provider/provider.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; +import '../../../../app_localizations.dart'; +import '../../../../app/constants/sc_global_config.dart'; +import '../../../../app/routes/sc_fluro_navigator.dart'; +import '../../../../shared/business_logic/models/res/sc_index_banner_res.dart'; +import '../../../../shared/tools/sc_banner_utils.dart'; +import '../../../../shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; +import '../../../../shared/business_logic/models/res/follow_room_res.dart'; +import '../../../../shared/business_logic/models/res/room_res.dart'; import '../../../../services/audio/rtc_manager.dart'; import '../../../../services/general/sc_app_general_manager.dart'; import '../../../../services/home/home_room_preload_manager.dart'; @@ -21,10 +21,10 @@ import '../../../../ui_kit/components/text/sc_text.dart'; import '../../../../ui_kit/widgets/room/room_live_audio_indicator.dart'; import '../../../../ui_kit/widgets/svga/sc_svga_asset_widget.dart'; import '../../../index/main_route.dart'; - -const Duration _kPartySkeletonAnimationDuration = Duration(milliseconds: 1450); -const Color _kPartySkeletonShell = Color(0xFF0F3730); -const Color _kPartySkeletonShellStrong = Color(0xFF1A4A41); + +const Duration _kPartySkeletonAnimationDuration = Duration(milliseconds: 1450); +const Color _kPartySkeletonShell = Color(0xFF0F3730); +const Color _kPartySkeletonShellStrong = Color(0xFF1A4A41); const Color _kPartySkeletonBoneBase = Color(0xFF2B5C53); const Color _kPartySkeletonBoneHighlight = Color(0xFF5F8177); const Color _kPartySkeletonBorder = Color(0x66D8B57B); @@ -42,12 +42,12 @@ class _RoomCountryFilterOption { } class SCHomePartyPage extends StatefulWidget { - const SCHomePartyPage({super.key}); - - @override - State createState() => _HomePartyPageState(); -} - + const SCHomePartyPage({super.key}); + + @override + State createState() => _HomePartyPageState(); +} + class _HomePartyPageState extends State with SingleTickerProviderStateMixin, @@ -67,8 +67,8 @@ class _HomePartyPageState extends State bool isLoading = false; bool _isBannerLoading = false; bool _isLeaderboardLoading = false; - final RefreshController _refreshController = RefreshController( - initialRefresh: false, + final RefreshController _refreshController = RefreshController( + initialRefresh: false, ); late final AnimationController _skeletonController; List rooms = []; @@ -121,63 +121,63 @@ class _HomePartyPageState extends State } _wasTickerModeEnabled = isTickerModeEnabled; } - + @override Widget build(BuildContext context) { super.build(context); return Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Colors.transparent, - body: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - SmartRefresher( - enablePullDown: true, - enablePullUp: false, - controller: _refreshController, - onRefresh: () { + resizeToAvoidBottomInset: false, + backgroundColor: Colors.transparent, + body: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + SmartRefresher( + enablePullDown: true, + enablePullUp: false, + controller: _refreshController, + onRefresh: () { loadData(forceRefresh: true); - }, - onLoading: () {}, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Consumer( - builder: (context, ref, child) { - if (!_shouldShowBannerSection(ref)) { - return const SizedBox.shrink(); - } - return Padding( - padding: EdgeInsets.only(bottom: 12.w), - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - if (_shouldShowBannerSkeleton(ref)) - _buildBannerSkeleton() - else - _banner(ref), - ], - ), - ); - }, - ), - Consumer( - builder: (context, ref, child) { - if (_shouldShowLeaderboardSkeleton(ref)) { - return _buildLeaderboardSkeleton(); - } - return _banner2(ref); - }, - ), - _buildRoomsSection(), - ], - ), - ), - ), - ], - ), - ); + }, + onLoading: () {}, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Consumer( + builder: (context, ref, child) { + if (!_shouldShowBannerSection(ref)) { + return const SizedBox.shrink(); + } + return Padding( + padding: EdgeInsets.only(bottom: 12.w), + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + if (_shouldShowBannerSkeleton(ref)) + _buildBannerSkeleton() + else + _banner(ref), + ], + ), + ); + }, + ), + Consumer( + builder: (context, ref, child) { + if (_shouldShowLeaderboardSkeleton(ref)) { + return _buildLeaderboardSkeleton(); + } + return _banner2(ref); + }, + ), + _buildRoomsSection(), + ], + ), + ), + ), + ], + ), + ); } void _startRoomListAutoRefresh() { @@ -218,8 +218,7 @@ class _HomePartyPageState extends State previous.roomCover == next.roomCover && previous.roomGameIcon == next.roomGameIcon && previous.displayMemberCount == next.displayMemberCount && - previous.extValues?.roomSetting?.password == - next.extValues?.roomSetting?.password; + previous.extValues?.existsPassword == next.extValues?.existsPassword; } List _mergeLatestRoomsWithCurrentCounters( @@ -414,299 +413,299 @@ class _HomePartyPageState extends State _isSilentRefreshingRooms = false; } } - - _banner(SCAppGeneralManager ref) { - final banners = _partyBanners(ref); - final hasMultipleBanners = banners.length > 1; - if (banners.isEmpty) { - return Container(); - } - return Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - CarouselSlider( - options: CarouselOptions( - height: 95.w, - autoPlay: hasMultipleBanners, - // 启用自动播放 - enlargeCenterPage: false, - // 居中放大当前页面 - enableInfiniteScroll: hasMultipleBanners, - // 启用无限循环 - autoPlayAnimationDuration: Duration(milliseconds: 800), - // 自动播放动画时长 - viewportFraction: 1, - scrollPhysics: - hasMultipleBanners - ? const BouncingScrollPhysics() - : const NeverScrollableScrollPhysics(), - // 视口分数 - onPageChanged: (index, reason) { - _currentIndex = index; - setState(() {}); - }, - ), - items: - banners.map((item) { - return GestureDetector( - child: netImage( - url: item.cover ?? "", - height: 95.w, - width: ScreenUtil().screenWidth * 0.9, - fit: BoxFit.fill, - borderRadius: BorderRadius.circular(12.w), - ), - onTap: () { - SCBannerUtils.openBanner(item, context); - }, - ); - }).toList(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: - banners.asMap().entries.map((entry) { - return Container( - width: 6.0, - height: 6.0, - margin: EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - _currentIndex == entry.key ? Colors.blue : Colors.white, - ), - ); - }).toList(), - ), - ], - ); - } - - List _partyBanners(SCAppGeneralManager ref) { - if (ref.exploreBanners.isNotEmpty) { - return ref.exploreBanners; - } - return ref.homeBanners; - } - - _banner2(SCAppGeneralManager ref) { - String roomGiftOneAvatar = ""; - String roomGiftTwoAvatar = ""; - String roomGiftThreeAvatar = ""; - if (ref.appLeaderResult?.roomGiftsLeaderboard != null && - ref.appLeaderResult?.roomGiftsLeaderboard?.weekly != null) { - if ((ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?.isNotEmpty ?? - false) && - ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?.first != null) { - roomGiftOneAvatar = - ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?.first.avatar ?? - ""; - } - - if (ref.appLeaderResult!.roomGiftsLeaderboard!.weekly!.length > 1 && - ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?[1] != null) { - roomGiftTwoAvatar = - ref.appLeaderResult?.roomGiftsLeaderboard?.weekly![1].avatar ?? ""; - } - if (ref.appLeaderResult!.roomGiftsLeaderboard!.weekly!.length > 2 && - ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?[2] != null) { - roomGiftThreeAvatar = - ref.appLeaderResult?.roomGiftsLeaderboard?.weekly![2].avatar ?? ""; - } - } - - String wealthOneAvatar = ""; - String wealthTwoAvatar = ""; - String wealthThreeAvatar = ""; - - if (ref.appLeaderResult?.giftsSendLeaderboard != null && - ref.appLeaderResult?.giftsSendLeaderboard?.weekly != null) { - if ((ref.appLeaderResult?.giftsSendLeaderboard?.weekly?.isNotEmpty ?? - false) && - ref.appLeaderResult?.giftsSendLeaderboard?.weekly?.first != null) { - wealthOneAvatar = - ref.appLeaderResult?.giftsSendLeaderboard?.weekly?.first.avatar ?? - ""; - } - - if (ref.appLeaderResult!.giftsSendLeaderboard!.weekly!.length > 1 && - ref.appLeaderResult?.giftsSendLeaderboard?.weekly?[1] != null) { - wealthTwoAvatar = - ref.appLeaderResult?.giftsSendLeaderboard?.weekly![1].avatar ?? ""; - } - if (ref.appLeaderResult!.giftsSendLeaderboard!.weekly!.length > 2 && - ref.appLeaderResult?.giftsSendLeaderboard?.weekly?[2] != null) { - wealthThreeAvatar = - ref.appLeaderResult?.giftsSendLeaderboard?.weekly![2].avatar ?? ""; - } - } - - String charmOneAvatar = ""; - String charmTwoAvatar = ""; - String charmThreeAvatar = ""; - - if (ref.appLeaderResult?.giftsReceivedLeaderboard != null && - ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly != null) { - if ((ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?.isNotEmpty ?? - false) && - ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?.first != - null) { - charmOneAvatar = - ref - .appLeaderResult - ?.giftsReceivedLeaderboard - ?.weekly - ?.first - .avatar ?? - ""; - } - - if (ref.appLeaderResult!.giftsReceivedLeaderboard!.weekly!.length > 1 && - ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?[1] != null) { - charmTwoAvatar = - ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly![1].avatar ?? - ""; - } - if (ref.appLeaderResult!.giftsReceivedLeaderboard!.weekly!.length > 2 && - ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?[2] != null) { - charmThreeAvatar = - ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly![2].avatar ?? - ""; - } - } - - if (roomGiftOneAvatar.isEmpty && - wealthOneAvatar.isEmpty && - charmOneAvatar.isEmpty) { - return Container(); - } - return SizedBox( - height: 130.w, - child: Row( - children: [ - Expanded( - child: GestureDetector( - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - PositionedDirectional( - start: 26.w, - top: 65.w, - child: head(url: wealthTwoAvatar, width: 30.w), - ), - Positioned( - top: 35.w, - child: head(url: wealthOneAvatar, width: 36.w), - ), - PositionedDirectional( - end: 24.w, - top: 65.w, - child: head(url: wealthThreeAvatar, width: 30.w), - ), - Image.asset( - SCGlobalConfig.businessLogicStrategy - .getPopularLeaderboardBackgroundImage('wealth'), - fit: BoxFit.fill, - ), - ], - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(SCGlobalConfig.wealthRankUrl)}&showTitle=false", - replace: false, - ); - }, - ), - ), - Expanded( - child: GestureDetector( - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - PositionedDirectional( - start: 24.w, - top: 68.w, - child: head(url: roomGiftTwoAvatar, width: 30.w), - ), - Positioned( - top: 35.w, - child: head(url: roomGiftOneAvatar, width: 38.w), - ), - PositionedDirectional( - end: 25.w, - top: 69.w, - child: head(url: roomGiftThreeAvatar, width: 30.w), - ), - Image.asset( - SCGlobalConfig.businessLogicStrategy - .getPopularLeaderboardBackgroundImage('room'), - fit: BoxFit.fill, - ), - ], - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(SCGlobalConfig.roomRankUrl)}&showTitle=false", - replace: false, - ); - }, - ), - ), - Expanded( - child: GestureDetector( - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - PositionedDirectional( - start: 26.w, - top: 66.w, - child: head(url: charmTwoAvatar, width: 30.w), - ), - Positioned( - top: 38.w, - child: head(url: charmOneAvatar, width: 37.w), - ), - PositionedDirectional( - end: 25.w, - top: 66.w, - child: head(url: charmThreeAvatar, width: 30.w), - ), - Image.asset( - SCGlobalConfig.businessLogicStrategy - .getPopularLeaderboardBackgroundImage('charm'), - fit: BoxFit.fill, - ), - ], - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(SCGlobalConfig.charmRankUrl)}&showTitle=false", - replace: false, - ); - }, - ), - ), - ], - ), - ); - } - + + _banner(SCAppGeneralManager ref) { + final banners = _partyBanners(ref); + final hasMultipleBanners = banners.length > 1; + if (banners.isEmpty) { + return Container(); + } + return Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + CarouselSlider( + options: CarouselOptions( + height: 95.w, + autoPlay: hasMultipleBanners, + // 启用自动播放 + enlargeCenterPage: false, + // 居中放大当前页面 + enableInfiniteScroll: hasMultipleBanners, + // 启用无限循环 + autoPlayAnimationDuration: Duration(milliseconds: 800), + // 自动播放动画时长 + viewportFraction: 1, + scrollPhysics: + hasMultipleBanners + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + // 视口分数 + onPageChanged: (index, reason) { + _currentIndex = index; + setState(() {}); + }, + ), + items: + banners.map((item) { + return GestureDetector( + child: netImage( + url: item.cover ?? "", + height: 95.w, + width: ScreenUtil().screenWidth * 0.9, + fit: BoxFit.fill, + borderRadius: BorderRadius.circular(12.w), + ), + onTap: () { + SCBannerUtils.openBanner(item, context); + }, + ); + }).toList(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: + banners.asMap().entries.map((entry) { + return Container( + width: 6.0, + height: 6.0, + margin: EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + _currentIndex == entry.key ? Colors.blue : Colors.white, + ), + ); + }).toList(), + ), + ], + ); + } + + List _partyBanners(SCAppGeneralManager ref) { + if (ref.exploreBanners.isNotEmpty) { + return ref.exploreBanners; + } + return ref.homeBanners; + } + + _banner2(SCAppGeneralManager ref) { + String roomGiftOneAvatar = ""; + String roomGiftTwoAvatar = ""; + String roomGiftThreeAvatar = ""; + if (ref.appLeaderResult?.roomGiftsLeaderboard != null && + ref.appLeaderResult?.roomGiftsLeaderboard?.weekly != null) { + if ((ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?.isNotEmpty ?? + false) && + ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?.first != null) { + roomGiftOneAvatar = + ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?.first.avatar ?? + ""; + } + + if (ref.appLeaderResult!.roomGiftsLeaderboard!.weekly!.length > 1 && + ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?[1] != null) { + roomGiftTwoAvatar = + ref.appLeaderResult?.roomGiftsLeaderboard?.weekly![1].avatar ?? ""; + } + if (ref.appLeaderResult!.roomGiftsLeaderboard!.weekly!.length > 2 && + ref.appLeaderResult?.roomGiftsLeaderboard?.weekly?[2] != null) { + roomGiftThreeAvatar = + ref.appLeaderResult?.roomGiftsLeaderboard?.weekly![2].avatar ?? ""; + } + } + + String wealthOneAvatar = ""; + String wealthTwoAvatar = ""; + String wealthThreeAvatar = ""; + + if (ref.appLeaderResult?.giftsSendLeaderboard != null && + ref.appLeaderResult?.giftsSendLeaderboard?.weekly != null) { + if ((ref.appLeaderResult?.giftsSendLeaderboard?.weekly?.isNotEmpty ?? + false) && + ref.appLeaderResult?.giftsSendLeaderboard?.weekly?.first != null) { + wealthOneAvatar = + ref.appLeaderResult?.giftsSendLeaderboard?.weekly?.first.avatar ?? + ""; + } + + if (ref.appLeaderResult!.giftsSendLeaderboard!.weekly!.length > 1 && + ref.appLeaderResult?.giftsSendLeaderboard?.weekly?[1] != null) { + wealthTwoAvatar = + ref.appLeaderResult?.giftsSendLeaderboard?.weekly![1].avatar ?? ""; + } + if (ref.appLeaderResult!.giftsSendLeaderboard!.weekly!.length > 2 && + ref.appLeaderResult?.giftsSendLeaderboard?.weekly?[2] != null) { + wealthThreeAvatar = + ref.appLeaderResult?.giftsSendLeaderboard?.weekly![2].avatar ?? ""; + } + } + + String charmOneAvatar = ""; + String charmTwoAvatar = ""; + String charmThreeAvatar = ""; + + if (ref.appLeaderResult?.giftsReceivedLeaderboard != null && + ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly != null) { + if ((ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?.isNotEmpty ?? + false) && + ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?.first != + null) { + charmOneAvatar = + ref + .appLeaderResult + ?.giftsReceivedLeaderboard + ?.weekly + ?.first + .avatar ?? + ""; + } + + if (ref.appLeaderResult!.giftsReceivedLeaderboard!.weekly!.length > 1 && + ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?[1] != null) { + charmTwoAvatar = + ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly![1].avatar ?? + ""; + } + if (ref.appLeaderResult!.giftsReceivedLeaderboard!.weekly!.length > 2 && + ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly?[2] != null) { + charmThreeAvatar = + ref.appLeaderResult?.giftsReceivedLeaderboard?.weekly![2].avatar ?? + ""; + } + } + + if (roomGiftOneAvatar.isEmpty && + wealthOneAvatar.isEmpty && + charmOneAvatar.isEmpty) { + return Container(); + } + return SizedBox( + height: 130.w, + child: Row( + children: [ + Expanded( + child: GestureDetector( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + PositionedDirectional( + start: 26.w, + top: 65.w, + child: head(url: wealthTwoAvatar, width: 30.w), + ), + Positioned( + top: 35.w, + child: head(url: wealthOneAvatar, width: 36.w), + ), + PositionedDirectional( + end: 24.w, + top: 65.w, + child: head(url: wealthThreeAvatar, width: 30.w), + ), + Image.asset( + SCGlobalConfig.businessLogicStrategy + .getPopularLeaderboardBackgroundImage('wealth'), + fit: BoxFit.fill, + ), + ], + ), + onTap: () { + SCNavigatorUtils.push( + context, + "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(SCGlobalConfig.wealthRankUrl)}&showTitle=false", + replace: false, + ); + }, + ), + ), + Expanded( + child: GestureDetector( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + PositionedDirectional( + start: 24.w, + top: 68.w, + child: head(url: roomGiftTwoAvatar, width: 30.w), + ), + Positioned( + top: 35.w, + child: head(url: roomGiftOneAvatar, width: 38.w), + ), + PositionedDirectional( + end: 25.w, + top: 69.w, + child: head(url: roomGiftThreeAvatar, width: 30.w), + ), + Image.asset( + SCGlobalConfig.businessLogicStrategy + .getPopularLeaderboardBackgroundImage('room'), + fit: BoxFit.fill, + ), + ], + ), + onTap: () { + SCNavigatorUtils.push( + context, + "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(SCGlobalConfig.roomRankUrl)}&showTitle=false", + replace: false, + ); + }, + ), + ), + Expanded( + child: GestureDetector( + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + PositionedDirectional( + start: 26.w, + top: 66.w, + child: head(url: charmTwoAvatar, width: 30.w), + ), + Positioned( + top: 38.w, + child: head(url: charmOneAvatar, width: 37.w), + ), + PositionedDirectional( + end: 25.w, + top: 66.w, + child: head(url: charmThreeAvatar, width: 30.w), + ), + Image.asset( + SCGlobalConfig.businessLogicStrategy + .getPopularLeaderboardBackgroundImage('charm'), + fit: BoxFit.fill, + ), + ], + ), + onTap: () { + SCNavigatorUtils.push( + context, + "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(SCGlobalConfig.charmRankUrl)}&showTitle=false", + replace: false, + ); + }, + ), + ), + ], + ), + ); + } + Widget _buildRoomsSection() { if (!_hasCompletedInitialRoomLoad && isLoading && rooms.isEmpty) { return _buildRoomGridSkeleton(); } - if (rooms.isEmpty) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - loadData(); - }, - child: mainEmpty(msg: SCAppLocalizations.of(context)!.noData), - ); - } + if (rooms.isEmpty) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + loadData(); + }, + child: mainEmpty(msg: SCAppLocalizations.of(context)!.noData), + ); + } final countryOptions = _roomCountryFilterOptions(); final visibleRooms = _filteredRooms(); final countrySelector = _buildCountryFilterSelector(countryOptions); @@ -888,334 +887,334 @@ class _HomePartyPageState extends State } bool _shouldShowLeaderboardSkeleton(SCAppGeneralManager ref) { - return _isLeaderboardLoading && !_hasLeaderboardData(ref); - } - - bool _shouldShowBannerSection(SCAppGeneralManager ref) { - return _shouldShowBannerSkeleton(ref) || _partyBanners(ref).isNotEmpty; - } - - bool _shouldShowBannerSkeleton(SCAppGeneralManager ref) { - return _isBannerLoading && _partyBanners(ref).isEmpty; - } - - bool _hasLeaderboardData(SCAppGeneralManager ref) { - final appLeaderResult = ref.appLeaderResult; - return (appLeaderResult?.giftsSendLeaderboard?.weekly?.isNotEmpty ?? - false) || - (appLeaderResult?.roomGiftsLeaderboard?.weekly?.isNotEmpty ?? false) || - (appLeaderResult?.giftsReceivedLeaderboard?.weekly?.isNotEmpty ?? - false); - } - - Widget _buildBannerSkeleton() { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 18.w), - child: SizedBox( - height: 95.w, - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.w), - border: Border.all(color: _kPartySkeletonBorder, width: 1.w), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [_kPartySkeletonShellStrong, _kPartySkeletonShell], - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.14), - blurRadius: 18.w, - offset: Offset(0, 8.w), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12.w), - child: Stack( - children: [ - Positioned.fill( - child: Opacity( - opacity: 0.45, - child: _buildSkeletonPanel(), - ), - ), - ], - ), - ), - ), - ), - Positioned( - bottom: 10.w, - child: Row( - children: List.generate(3, (index) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 4.w), - child: _buildSkeletonBone( - width: 6.w, - height: 6.w, - shape: BoxShape.circle, - ), - ); - }), - ), - ), - ], - ), - ), - ); - } - - Widget _buildLeaderboardSkeleton() { - return SizedBox( - height: 130.w, - child: Row( - children: List.generate(3, (index) { - final bool isCenterCard = index == 1; - return Expanded( - child: Padding( - padding: EdgeInsets.fromLTRB( - 4.w, - isCenterCard ? 0 : 6.w, - 4.w, - isCenterCard ? 0 : 6.w, - ), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18.w), - border: Border.all(color: _kPartySkeletonBorder, width: 1.w), - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [_kPartySkeletonShellStrong, _kPartySkeletonShell], - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 14.w, - offset: Offset(0, 8.w), - ), - ], - ), - child: Stack( - alignment: Alignment.center, - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.all(1.w), - child: ClipRRect( - borderRadius: BorderRadius.circular(17.w), - child: Opacity( - opacity: 0.34, - child: _buildSkeletonPanel(), - ), - ), - ), - ), - Positioned( - top: 21.w, - child: _buildSkeletonBone( - width: isCenterCard ? 42.w : 38.w, - height: isCenterCard ? 42.w : 38.w, - shape: BoxShape.circle, - ), - ), - PositionedDirectional( - start: 16.w, - top: 56.w, - child: _buildSkeletonBone( - width: 28.w, - height: 28.w, - shape: BoxShape.circle, - ), - ), - PositionedDirectional( - end: 16.w, - top: 56.w, - child: _buildSkeletonBone( - width: 28.w, - height: 28.w, - shape: BoxShape.circle, - ), - ), - Positioned( - bottom: 16.w, - child: _buildSkeletonBone( - width: isCenterCard ? 56.w : 48.w, - height: 10.w, - borderRadius: BorderRadius.circular(999.w), - ), - ), - ], - ), - ), - ), - ); - }), - ), - ); - } - - Widget _buildRoomGridSkeleton() { - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - ), - padding: EdgeInsets.all(10.w), - itemCount: 6, - itemBuilder: (context, index) => _buildRoomSkeletonCard(), - ); - } - - Widget _buildRoomSkeletonCard() { - return Container( - margin: EdgeInsets.symmetric(horizontal: 5.w, vertical: 5.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14.w), - border: Border.all(color: _kPartySkeletonBorder, width: 1.w), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.16), - blurRadius: 16.w, - offset: Offset(0, 8.w), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(14.w), - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [_kPartySkeletonShellStrong, _kPartySkeletonShell], - ), - ), - ), - ), - Positioned.fill( - child: Padding( - padding: EdgeInsets.only(bottom: 34.w), - child: _buildSkeletonPanel( - borderRadius: BorderRadius.vertical( - top: Radius.circular(14.w), - ), - ), - ), - ), - Positioned( - top: 12.w, - right: 12.w, - child: _buildSkeletonBone( - width: 30.w, - height: 12.w, - borderRadius: BorderRadius.circular(999.w), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - height: 34.w, - padding: EdgeInsets.symmetric(horizontal: 10.w), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.24), - border: Border( - top: BorderSide( - color: Colors.white.withValues(alpha: 0.05), - width: 1.w, - ), - ), - ), - child: Row( - children: [ - _buildSkeletonBone( - width: 20.w, - height: 13.w, - borderRadius: BorderRadius.circular(3.w), - ), - SizedBox(width: 6.w), - Expanded( - child: _buildSkeletonBone( - height: 10.w, - borderRadius: BorderRadius.circular(999.w), - ), - ), - SizedBox(width: 8.w), - _buildSkeletonBone( - width: 18.w, - height: 10.w, - borderRadius: BorderRadius.circular(999.w), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildSkeletonPanel({ - BorderRadius? borderRadius, - BoxShape shape = BoxShape.rectangle, - }) { - return AnimatedBuilder( - animation: _skeletonController, - builder: (context, child) { - final double slideOffset = (_skeletonController.value * 2) - 1; - return DecoratedBox( - decoration: BoxDecoration( - shape: shape, - borderRadius: - shape == BoxShape.circle - ? null - : (borderRadius ?? BorderRadius.circular(12.w)), - gradient: LinearGradient( - begin: Alignment(-1.4 + slideOffset, -0.2), - end: Alignment(1.4 + slideOffset, 0.2), - colors: const [ - _kPartySkeletonBoneBase, - _kPartySkeletonBoneBase, - _kPartySkeletonBoneHighlight, - _kPartySkeletonBoneBase, - ], - stops: const [0.0, 0.36, 0.54, 1.0], - ), - ), - ); - }, - ); - } - - Widget _buildSkeletonBone({ - double? width, - double? height, - BorderRadius? borderRadius, - BoxShape shape = BoxShape.rectangle, - }) { - return SizedBox( - width: width, - height: height, - child: _buildSkeletonPanel(borderRadius: borderRadius, shape: shape), - ); - } - + return _isLeaderboardLoading && !_hasLeaderboardData(ref); + } + + bool _shouldShowBannerSection(SCAppGeneralManager ref) { + return _shouldShowBannerSkeleton(ref) || _partyBanners(ref).isNotEmpty; + } + + bool _shouldShowBannerSkeleton(SCAppGeneralManager ref) { + return _isBannerLoading && _partyBanners(ref).isEmpty; + } + + bool _hasLeaderboardData(SCAppGeneralManager ref) { + final appLeaderResult = ref.appLeaderResult; + return (appLeaderResult?.giftsSendLeaderboard?.weekly?.isNotEmpty ?? + false) || + (appLeaderResult?.roomGiftsLeaderboard?.weekly?.isNotEmpty ?? false) || + (appLeaderResult?.giftsReceivedLeaderboard?.weekly?.isNotEmpty ?? + false); + } + + Widget _buildBannerSkeleton() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 18.w), + child: SizedBox( + height: 95.w, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.w), + border: Border.all(color: _kPartySkeletonBorder, width: 1.w), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_kPartySkeletonShellStrong, _kPartySkeletonShell], + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.14), + blurRadius: 18.w, + offset: Offset(0, 8.w), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.w), + child: Stack( + children: [ + Positioned.fill( + child: Opacity( + opacity: 0.45, + child: _buildSkeletonPanel(), + ), + ), + ], + ), + ), + ), + ), + Positioned( + bottom: 10.w, + child: Row( + children: List.generate(3, (index) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 4.w), + child: _buildSkeletonBone( + width: 6.w, + height: 6.w, + shape: BoxShape.circle, + ), + ); + }), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLeaderboardSkeleton() { + return SizedBox( + height: 130.w, + child: Row( + children: List.generate(3, (index) { + final bool isCenterCard = index == 1; + return Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB( + 4.w, + isCenterCard ? 0 : 6.w, + 4.w, + isCenterCard ? 0 : 6.w, + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18.w), + border: Border.all(color: _kPartySkeletonBorder, width: 1.w), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_kPartySkeletonShellStrong, _kPartySkeletonShell], + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 14.w, + offset: Offset(0, 8.w), + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.all(1.w), + child: ClipRRect( + borderRadius: BorderRadius.circular(17.w), + child: Opacity( + opacity: 0.34, + child: _buildSkeletonPanel(), + ), + ), + ), + ), + Positioned( + top: 21.w, + child: _buildSkeletonBone( + width: isCenterCard ? 42.w : 38.w, + height: isCenterCard ? 42.w : 38.w, + shape: BoxShape.circle, + ), + ), + PositionedDirectional( + start: 16.w, + top: 56.w, + child: _buildSkeletonBone( + width: 28.w, + height: 28.w, + shape: BoxShape.circle, + ), + ), + PositionedDirectional( + end: 16.w, + top: 56.w, + child: _buildSkeletonBone( + width: 28.w, + height: 28.w, + shape: BoxShape.circle, + ), + ), + Positioned( + bottom: 16.w, + child: _buildSkeletonBone( + width: isCenterCard ? 56.w : 48.w, + height: 10.w, + borderRadius: BorderRadius.circular(999.w), + ), + ), + ], + ), + ), + ), + ); + }), + ), + ); + } + + Widget _buildRoomGridSkeleton() { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + padding: EdgeInsets.all(10.w), + itemCount: 6, + itemBuilder: (context, index) => _buildRoomSkeletonCard(), + ); + } + + Widget _buildRoomSkeletonCard() { + return Container( + margin: EdgeInsets.symmetric(horizontal: 5.w, vertical: 5.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14.w), + border: Border.all(color: _kPartySkeletonBorder, width: 1.w), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.16), + blurRadius: 16.w, + offset: Offset(0, 8.w), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14.w), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_kPartySkeletonShellStrong, _kPartySkeletonShell], + ), + ), + ), + ), + Positioned.fill( + child: Padding( + padding: EdgeInsets.only(bottom: 34.w), + child: _buildSkeletonPanel( + borderRadius: BorderRadius.vertical( + top: Radius.circular(14.w), + ), + ), + ), + ), + Positioned( + top: 12.w, + right: 12.w, + child: _buildSkeletonBone( + width: 30.w, + height: 12.w, + borderRadius: BorderRadius.circular(999.w), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: 34.w, + padding: EdgeInsets.symmetric(horizontal: 10.w), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.24), + border: Border( + top: BorderSide( + color: Colors.white.withValues(alpha: 0.05), + width: 1.w, + ), + ), + ), + child: Row( + children: [ + _buildSkeletonBone( + width: 20.w, + height: 13.w, + borderRadius: BorderRadius.circular(3.w), + ), + SizedBox(width: 6.w), + Expanded( + child: _buildSkeletonBone( + height: 10.w, + borderRadius: BorderRadius.circular(999.w), + ), + ), + SizedBox(width: 8.w), + _buildSkeletonBone( + width: 18.w, + height: 10.w, + borderRadius: BorderRadius.circular(999.w), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSkeletonPanel({ + BorderRadius? borderRadius, + BoxShape shape = BoxShape.rectangle, + }) { + return AnimatedBuilder( + animation: _skeletonController, + builder: (context, child) { + final double slideOffset = (_skeletonController.value * 2) - 1; + return DecoratedBox( + decoration: BoxDecoration( + shape: shape, + borderRadius: + shape == BoxShape.circle + ? null + : (borderRadius ?? BorderRadius.circular(12.w)), + gradient: LinearGradient( + begin: Alignment(-1.4 + slideOffset, -0.2), + end: Alignment(1.4 + slideOffset, 0.2), + colors: const [ + _kPartySkeletonBoneBase, + _kPartySkeletonBoneBase, + _kPartySkeletonBoneHighlight, + _kPartySkeletonBoneBase, + ], + stops: const [0.0, 0.36, 0.54, 1.0], + ), + ), + ); + }, + ); + } + + Widget _buildSkeletonBone({ + double? width, + double? height, + BorderRadius? borderRadius, + BoxShape shape = BoxShape.rectangle, + }) { + return SizedBox( + width: width, + height: height, + child: _buildSkeletonPanel(borderRadius: borderRadius, shape: shape), + ); + } + loadData({bool forceRefresh = false}) { final generalManager = Provider.of( context, @@ -1249,24 +1248,24 @@ class _HomePartyPageState extends State _hasCompletedInitialRoomLoad = true; if (mounted) setState(() {}); }); - generalManager.loadMainBanner().whenComplete(() { - if (!mounted) { - return; - } - setState(() { - _isBannerLoading = false; - }); - }); - generalManager.appLeaderboard().whenComplete(() { - if (!mounted) { - return; - } - setState(() { - _isLeaderboardLoading = false; - }); - }); - } - + generalManager.loadMainBanner().whenComplete(() { + if (!mounted) { + return; + } + setState(() { + _isBannerLoading = false; + }); + }); + generalManager.appLeaderboard().whenComplete(() { + if (!mounted) { + return; + } + setState(() { + _isLeaderboardLoading = false; + }); + }); + } + _buildItem(SocialChatRoomRes res, int index) { final rankBorderAsset = index < _topRankBorderAssets.length @@ -1286,22 +1285,22 @@ class _HomePartyPageState extends State clipBehavior: Clip.none, alignment: Alignment.bottomCenter, children: [ - Container( - padding: EdgeInsets.all(3.w), - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage("sc_images/index/sc_icon_room_bord.png"), - fit: BoxFit.fill, - ), - ), - child: netImage( - url: resolveRoomCoverUrl(res.id, res.roomCover), - defaultImg: kRoomCoverDefaultImg, - borderRadius: BorderRadius.circular(12.w), - width: 200.w, - height: 200.w, - ), - ), + Container( + padding: EdgeInsets.all(3.w), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage("sc_images/index/sc_icon_room_bord.png"), + fit: BoxFit.fill, + ), + ), + child: netImage( + url: resolveRoomCoverUrl(res.id, res.roomCover), + defaultImg: kRoomCoverDefaultImg, + borderRadius: BorderRadius.circular(12.w), + width: 200.w, + height: 200.w, + ), + ), Padding( padding: EdgeInsets.only( left: rankInfoHorizontalInset, @@ -1385,17 +1384,17 @@ class _HomePartyPageState extends State ), ), SizedBox(width: 5.w), - (res.extValues?.roomSetting?.password?.isEmpty ?? false) + res.extValues?.existsPassword != true ? SCRoomLiveAudioIndicator(width: 14.w, height: 14.w) : Image.asset( "sc_images/index/sc_icon_room_suo.png", width: 20.w, height: 20.w, ), - (res.extValues?.roomSetting?.password?.isEmpty ?? false) + res.extValues?.existsPassword != true ? SizedBox(width: 3.w) : Container(height: 10.w), - (res.extValues?.roomSetting?.password?.isEmpty ?? false) + res.extValues?.existsPassword != true ? text( res.displayMemberCount, fontSize: 10.sp, @@ -1433,4 +1432,4 @@ class _HomePartyPageState extends State }, ); } -} +} diff --git a/lib/modules/room/edit/room_edit_page.dart b/lib/modules/room/edit/room_edit_page.dart index 27e3e5c..7966b26 100644 --- a/lib/modules/room/edit/room_edit_page.dart +++ b/lib/modules/room/edit/room_edit_page.dart @@ -1,23 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/shared/tools/sc_loading_manager.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/services/audio/rtc_manager.dart'; import 'package:provider/provider.dart'; import 'package:yumi/app_localizations.dart'; -import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart'; import 'package:yumi/ui_kit/components/dialog/dialog_base.dart'; -import 'package:yumi/app/constants/sc_screen.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; import 'package:yumi/shared/tools/sc_pick_utils.dart'; import 'package:yumi/services/room/rc_room_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import '../../../shared/tools/sc_lk_dialog_util.dart'; import '../../../shared/data_sources/models/enum/sc_room_info_event_type.dart'; @@ -38,17 +38,25 @@ class RoomEditPage extends StatefulWidget { class _RoomEditPageState extends State { static const Set _roomGroupReadyCodes = {0, 10021, 10025}; + static const Color _backgroundColor = Color(0xff071E1D); + static const Color _fieldColor = Color(0xff08251E); + static const Color _fieldBorderColor = Color(0xffB2FBCC); + static const Color _dangerColor = Color(0xffff0000); RtcProvider? rtcProvider; bool isEdit = false; final TextEditingController _roomNameController = TextEditingController(); final TextEditingController _roomAnnouncementController = TextEditingController(); + final TextEditingController _roomLockPasswordController = + TextEditingController(); final int _roomNameMaxLength = 24; int _roomNameCurrentLength = 0; String roomCover = ""; - final int _roomAnnouncementMaxLength = 100; + final int _roomAnnouncementMaxLength = 24; int _roomAnnouncementCurrentLength = 0; + bool _initialRoomLocked = false; + bool _isRoomPrivate = false; @override void initState() { @@ -78,425 +86,602 @@ class _RoomEditPageState extends State { ); roomCover = rtcProvider?.currenRoom?.roomProfile?.roomProfile?.roomCover ?? ""; + final roomPassword = + rtcProvider?.currenRoom?.roomProfile?.roomSetting?.password?.trim() ?? + ""; + _initialRoomLocked = roomPassword.isNotEmpty; + _isRoomPrivate = _initialRoomLocked; } @override void dispose() { _roomNameController.dispose(); _roomAnnouncementController.dispose(); + _roomLockPasswordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () { - if (!isEdit) { - SCNavigatorUtils.goBack(context); - return Future.value(false); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + return; } - SmartDialog.show( - tag: "showConfirmDialog", - alignment: Alignment.center, - debounce: true, - animationType: SmartAnimationType.fade, - builder: (_) { - return MsgDialog( - title: SCAppLocalizations.of(context)!.tips, - msg: SCAppLocalizations.of(context)!.theModificationsMade, - onEnsure: () { - SCNavigatorUtils.goBack(context); - }, - ); - }, - ); - return Future.value(false); + _handleSystemBack(); }, - child: Stack( - children: [ - Image.asset( - "sc_images/person/sc_icon_edit_userinfo_bg.png", - width: ScreenUtil().screenWidth, - height: ScreenUtil().screenHeight, - fit: BoxFit.fill, + child: AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: _backgroundColor, + body: _buildBody(context), + ), + ), + ); + } + + Widget _buildBody(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final bottomPadding = + mediaQuery.viewPadding.bottom > 34.w + ? 34.w + : mediaQuery.viewPadding.bottom; + return Column( + children: [ + SizedBox(height: mediaQuery.viewPadding.top), + _buildNavigationBar(context), + Expanded( + child: Stack( + children: [ + SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only(bottom: bottomPadding + 116.w), + child: _buildForm(context), + ), + Positioned( + left: 24.w, + right: 24.w, + bottom: bottomPadding + 14.w, + child: _buildSaveButton(context), + ), + ], ), - Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Colors.transparent, - appBar: SocialChatStandardAppBar( - title: SCAppLocalizations.of(context)!.roomSetting, - onTag: () { - if (widget.needRestCurrentRoomInfo == "true") { - Provider.of(context, listen: false).currenRoom = - null; - } - Navigator.pop(context); - }, - actions: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 5.w), - margin: EdgeInsetsDirectional.only(end: 8.w), - child: text( - SCAppLocalizations.of(context)!.save, - textColor: Colors.white, - fontSize: 15.sp, + ), + ], + ); + } + + Widget _buildNavigationBar(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 44.w, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleBack, + child: SizedBox( + width: 56.w, + height: 44.w, + child: Padding( + padding: EdgeInsets.only(left: 16.w), + child: Align( + alignment: Alignment.centerLeft, + child: Icon( + Icons.keyboard_arrow_left, + size: 24.w, + color: Colors.white, ), ), - onTap: () { - submit(context); - }, ), - ], - ), - body: SingleChildScrollView( - child: Column( - children: [ - SizedBox(height: 35.w), - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SizedBox(width: 20.w), - GestureDetector( - child: Stack( - alignment: Alignment.bottomRight, - children: [ - netImage( - url: roomCover, - defaultImg: kRoomCoverDefaultImg, - borderRadius: BorderRadius.all( - Radius.circular(12), - ), - width: 90.w, - height: 90.w, - ), - Icon( - Icons.camera_alt, - size: 25.w, - color: Colors.white, - ), - ], - ), - onTap: () { - SCPickUtils.pickImage(context, ( - bool success, - String url, - ) { - if (success) { - setState(() { - roomCover = url; - isEdit = true; - }); - } - }); - }, - ), - ], - ), - SizedBox(height: 25.w), - Row( - children: [ - SizedBox(width: 25.w), - text( - SCAppLocalizations.of(context)!.roomName, - textColor: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 15.sp, - ), - ], - ), - SizedBox(height: 10.w), - Row( - children: [ - Expanded( - child: Container( - margin: EdgeInsets.symmetric(horizontal: 25.w), - padding: EdgeInsets.only( - left: width(12), - right: width(12), - ), - alignment: AlignmentDirectional.centerStart, - height: 45.w, - width: ScreenUtil().screenWidth, - decoration: BoxDecoration( - border: Border.all( - color: Color(0xff5FFFB7), - width: 0.5, - ), - gradient: LinearGradient( - colors: [ - Color(0xff5FFFB7).withOpacity(0.1), - Color(0xff5FFFB7).withOpacity(0.6), - ], - ), - borderRadius: BorderRadius.all( - Radius.circular(height(8)), - ), - ), - child: TextField( - controller: _roomNameController, - onChanged: (text) { - isEdit = true; - }, - inputFormatters: [ - SCAccurateLengthLimitingTextInputFormatter( - _roomNameMaxLength, - ), - ], - decoration: InputDecoration( - hintText: - SCAppLocalizations.of(context)!.enterRoomName, - hintStyle: TextStyle( - color: Colors.white54, - fontSize: 14.sp, - ), - contentPadding: EdgeInsets.only(top: 0.w), - counterText: '', - isDense: true, - filled: false, - focusColor: Colors.transparent, - hoverColor: Colors.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - // enabledBorder: UnderlineInputBorder( - // borderSide: BorderSide(width: 0.5,color: Colors.white,style: BorderStyle.solid),), - // focusedBorder: UnderlineInputBorder( - // borderSide: BorderSide(width: 0.5,color: Colors.white,style: BorderStyle.solid),), - // prefixIcon: Padding(padding: EdgeInsets.all(8.w), child: Image.asset("images/login/sc_icon_phone.png",width: 20.w, height: 20.w,fit: BoxFit.fill,),), - fillColor: Colors.white54, - suffix: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: 3.w), - text( - '$_roomNameCurrentLength/$_roomNameMaxLength', - textColor: Colors.white54, - ), - ], - ), - ), - style: TextStyle( - fontSize: sp(15), - color: Colors.white, - textBaseline: TextBaseline.alphabetic, - ), - ), - ), - ), - ], - ), - SizedBox(height: 15.w), - Row( - children: [ - SizedBox(width: 25.w), - text( - SCAppLocalizations.of(context)!.roomAnnouncement, - textColor: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 15.sp, - ), - ], - ), - SizedBox(height: 10.w), - Row( - children: [ - Expanded( - child: Container( - margin: EdgeInsets.symmetric(horizontal: 25.w), - padding: EdgeInsets.only( - top: 5.w, - left: width(12), - right: width(12), - ), - alignment: AlignmentDirectional.centerStart, - height: 110.w, - width: ScreenUtil().screenWidth, - decoration: BoxDecoration( - border: Border.all( - color: Color(0xff5FFFB7), - width: 0.5, - ), - gradient: LinearGradient( - colors: [ - Color(0xff5FFFB7).withOpacity(0.1), - Color(0xff5FFFB7).withOpacity(0.6), - ], - ), - borderRadius: BorderRadius.all( - Radius.circular(height(8)), - ), - ), - child: TextField( - controller: _roomAnnouncementController, - onChanged: (text) { - isEdit = true; - }, - inputFormatters: [ - SCAccurateLengthLimitingTextInputFormatter( - _roomAnnouncementMaxLength, - ), - ], - maxLines: 5, - decoration: InputDecoration( - hintText: "", - hintStyle: TextStyle( - color: Colors.white54, - fontSize: 14.sp, - ), - contentPadding: EdgeInsets.only(top: 0.w), - counterText: '', - isDense: true, - filled: false, - focusColor: Colors.transparent, - hoverColor: Colors.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - // enabledBorder: UnderlineInputBorder( - // borderSide: BorderSide(width: 0.5,color: Colors.white,style: BorderStyle.solid),), - // focusedBorder: UnderlineInputBorder( - // borderSide: BorderSide(width: 0.5,color: Colors.white,style: BorderStyle.solid),), - // prefixIcon: Padding(padding: EdgeInsets.all(8.w), child: Image.asset("images/login/sc_icon_phone.png",width: 20.w, height: 20.w,fit: BoxFit.fill,),), - fillColor: Colors.white54, - suffix: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: 3.w), - text( - '$_roomAnnouncementCurrentLength/$_roomAnnouncementMaxLength', - textColor: Colors.white54, - ), - ], - ), - ), - style: TextStyle( - fontSize: sp(15), - color: Colors.white, - textBaseline: TextBaseline.alphabetic, - ), - ), - ), - ), - ], - ), - widget.needRestCurrentRoomInfo != "true" - ? Column( - children: [ - SizedBox(height: 12.w), - SCDebounceWidget( - child: Row( - children: [ - SizedBox(width: 25.w), - text( - SCAppLocalizations.of(context)!.roomTheme2, - textColor: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 15.sp, - ), - Spacer(), - Icon( - Icons.keyboard_arrow_right, - color: Colors.white, - size: 20.w, - ), - SizedBox(width: 25.w), - ], - ), - onTap: () { - VoiceRoomRoute.openRoomTheme(context); - }, - ), - SizedBox(height: 12.w), - SCDebounceWidget( - child: Row( - children: [ - SizedBox(width: 25.w), - text( - SCAppLocalizations.of(context)!.micManagement, - textColor: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 15.sp, - ), - Spacer(), - Icon( - Icons.keyboard_arrow_right, - color: Colors.white, - size: 20.w, - ), - SizedBox(width: 25.w), - ], - ), - onTap: () { - SmartDialog.show( - tag: "showRoomMicSwitch", - alignment: Alignment.bottomCenter, - animationType: SmartAnimationType.fade, - debounce: true, - builder: (_) { - return RoomMicSwitchPage(); - }, - ); - }, - ), - SizedBox(height: 12.w), - SCDebounceWidget( - child: Row( - children: [ - SizedBox(width: 25.w), - text( - SCAppLocalizations.of(context)!.blockedList2, - textColor: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 15.sp, - ), - Spacer(), - Icon( - Icons.keyboard_arrow_right, - color: Colors.white, - size: 20.w, - ), - SizedBox(width: 25.w), - ], - ), - onTap: () { - showBottomInBottomDialog( - context, - BlockedListPage( - roomId: - Provider.of( - context, - listen: false, - ) - .currenRoom - ?.roomProfile - ?.roomProfile - ?.id ?? - "", - ), - ); - }, - ), - SizedBox(height: 15.w), - ], - ) - : Container(), - ], ), ), ), + Text( + "Room Setup", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.w500, + height: 1, + ), + ), ], ), ); } + Widget _buildForm(BuildContext context) { + return Column( + children: [ + SizedBox(height: 16.w), + _buildRoomAvatar(), + SizedBox(height: 20.w), + _buildTextInput( + controller: _roomNameController, + height: 40.w, + hintText: "Please enter a name", + counterText: '$_roomNameCurrentLength/$_roomNameMaxLength', + inputFormatters: [ + SCAccurateLengthLimitingTextInputFormatter(_roomNameMaxLength), + ], + onChanged: (_) { + isEdit = true; + }, + ), + SizedBox(height: 16.w), + _buildSectionHeader( + title: "Notice", + counterText: + '$_roomAnnouncementCurrentLength/$_roomAnnouncementMaxLength', + ), + SizedBox(height: 12.w), + _buildTextInput( + controller: _roomAnnouncementController, + height: 135.w, + hintText: "Please enter a name", + maxLines: null, + expands: true, + inputFormatters: [ + SCAccurateLengthLimitingTextInputFormatter( + _roomAnnouncementMaxLength, + ), + ], + onChanged: (_) { + isEdit = true; + }, + ), + SizedBox(height: 16.w), + _buildLockHeader(), + SizedBox(height: 8.w), + _buildTextInput( + controller: _roomLockPasswordController, + height: 40.w, + hintText: "Please enter password", + keyboardType: TextInputType.visiblePassword, + obscureText: true, + onChanged: (_) { + isEdit = true; + }, + ), + SizedBox(height: 8.w), + _buildLockTips(), + if (widget.needRestCurrentRoomInfo != "true") ...[ + SizedBox(height: 16.w), + _buildSettingRow( + icon: Icons.dashboard_rounded, + title: SCAppLocalizations.of(context)!.roomTheme2, + onTap: () { + VoiceRoomRoute.openRoomTheme(context); + }, + ), + SizedBox(height: 8.w), + _buildSettingRow( + icon: Icons.mic_none_rounded, + title: "Microphone Settings", + onTap: () { + SmartDialog.show( + tag: "showRoomMicSwitch", + alignment: Alignment.bottomCenter, + animationType: SmartAnimationType.fade, + debounce: true, + builder: (_) { + return RoomMicSwitchPage(); + }, + ); + }, + ), + SizedBox(height: 8.w), + _buildSettingRow( + icon: Icons.person_off_rounded, + title: "Block List", + onTap: () { + showBottomInBottomDialog( + context, + BlockedListPage( + roomId: + Provider.of( + context, + listen: false, + ).currenRoom?.roomProfile?.roomProfile?.id ?? + "", + ), + ); + }, + ), + ], + ], + ); + } + + Widget _buildRoomAvatar() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + SCPickUtils.pickImage(context, (bool success, String url) { + if (success) { + setState(() { + roomCover = url; + isEdit = true; + }); + } + }); + }, + child: ClipOval( + child: netImage( + url: roomCover, + defaultImg: kRoomCoverDefaultImg, + width: 90.w, + height: 90.w, + fit: BoxFit.cover, + shape: BoxShape.circle, + ), + ), + ); + } + + Widget _buildTextInput({ + required TextEditingController controller, + required double height, + required String hintText, + String? counterText, + int? maxLines = 1, + bool expands = false, + bool obscureText = false, + TextInputType? keyboardType, + List? inputFormatters, + ValueChanged? onChanged, + }) { + final isMultiline = maxLines == null || expands; + return Container( + height: height, + margin: EdgeInsets.symmetric(horizontal: 10.w), + decoration: BoxDecoration( + color: _fieldColor, + borderRadius: BorderRadius.circular(8.w), + border: Border.all(color: _fieldBorderColor, width: 1), + ), + child: Stack( + alignment: isMultiline ? Alignment.topLeft : Alignment.centerLeft, + children: [ + Padding( + padding: EdgeInsets.only( + left: 12.w, + right: counterText == null ? 12.w : 56.w, + top: isMultiline ? 10.w : 0, + bottom: isMultiline ? 10.w : 0, + ), + child: TextField( + controller: controller, + cursorColor: SocialChatTheme.primaryColor, + keyboardType: keyboardType, + obscureText: obscureText, + inputFormatters: inputFormatters, + maxLines: maxLines, + expands: expands, + onChanged: onChanged, + textAlignVertical: + isMultiline + ? TextAlignVertical.top + : TextAlignVertical.center, + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle( + color: Colors.white, + fontSize: 14.sp, + height: 16 / 14, + ), + filled: true, + fillColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + counterText: '', + isDense: true, + isCollapsed: !isMultiline, + contentPadding: EdgeInsets.zero, + ), + style: TextStyle( + color: Colors.white, + backgroundColor: Colors.transparent, + fontSize: 14.sp, + fontWeight: FontWeight.w400, + height: 16 / 14, + ), + ), + ), + if (counterText != null) + PositionedDirectional( + end: 12.w, + top: 0, + bottom: 0, + child: Center( + child: Text( + counterText, + textAlign: TextAlign.right, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 14.sp, + fontWeight: FontWeight.w400, + height: 16 / 14, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSectionHeader({required String title, String? counterText}) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: SizedBox( + height: 16.w, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w500, + height: 1, + ), + ), + const Spacer(), + if (counterText != null) + Text( + counterText, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 12.sp, + fontWeight: FontWeight.w400, + height: 1, + ), + ), + ], + ), + ), + ); + } + + Widget _buildLockHeader() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: SizedBox( + height: 20.w, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "Room lock", + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w500, + height: 1, + ), + ), + const Spacer(), + _buildLockOption( + selected: !_isRoomPrivate, + label: "Public", + onTap: () { + setState(() { + if (_isRoomPrivate) { + isEdit = true; + } + _isRoomPrivate = false; + }); + }, + ), + SizedBox(width: 8.w), + _buildLockOption( + selected: _isRoomPrivate, + label: "Private", + onTap: () { + setState(() { + if (!_isRoomPrivate) { + isEdit = true; + } + _isRoomPrivate = true; + }); + }, + ), + ], + ), + ), + ); + } + + Widget _buildLockOption({ + required bool selected, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + height: 20.w, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 20.w, + height: 20.w, + child: Center( + child: Container( + width: 14.w, + height: 14.w, + decoration: BoxDecoration( + color: + selected + ? SocialChatTheme.primaryColor + : Colors.transparent, + borderRadius: BorderRadius.circular(2.w), + border: Border.all( + color: SocialChatTheme.primaryColor, + width: 1.w, + ), + ), + child: + selected + ? Icon(Icons.check, color: Colors.white, size: 11.w) + : null, + ), + ), + ), + SizedBox(width: 4.w), + Text( + label, + style: TextStyle( + color: + selected + ? Colors.white + : Colors.white.withValues(alpha: 0.6), + fontSize: 12.sp, + fontWeight: FontWeight.w400, + height: 1, + ), + ), + ], + ), + ), + ); + } + + Widget _buildLockTips() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: SizedBox( + width: double.infinity, + child: Text( + "After locking the door, enter the correct password to enter the room.", + maxLines: 2, + style: TextStyle( + color: _dangerColor, + fontSize: 14.sp, + fontWeight: FontWeight.w400, + height: 16 / 14, + ), + ), + ), + ); + } + + Widget _buildSettingRow({ + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: SCDebounceWidget( + onTap: onTap, + child: Container( + height: 40.w, + decoration: BoxDecoration( + color: _fieldColor, + borderRadius: BorderRadius.circular(8.w), + border: Border.all(color: _fieldBorderColor, width: 1), + ), + child: Row( + children: [ + SizedBox(width: 8.w), + Icon(icon, color: Colors.white, size: 24.w), + SizedBox(width: 4.w), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w400, + height: 16 / 14, + ), + ), + ), + Icon(Icons.keyboard_arrow_right, color: Colors.white, size: 24.w), + SizedBox(width: 8.w), + ], + ), + ), + ), + ); + } + + Widget _buildSaveButton(BuildContext context) { + return SCDebounceWidget( + onTap: () { + submit(context); + }, + child: Container( + height: 44.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: SocialChatTheme.primaryColor, + borderRadius: BorderRadius.circular(22.w), + ), + child: Text( + SCAppLocalizations.of(context)!.save, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black, + fontSize: 20.sp, + fontWeight: FontWeight.w500, + height: 1, + ), + ), + ), + ); + } + + void _handleBack() { + if (widget.needRestCurrentRoomInfo == "true") { + Provider.of(context, listen: false).currenRoom = null; + } + Navigator.pop(context); + } + + void _handleSystemBack() { + if (!isEdit) { + SCNavigatorUtils.goBack(context); + return; + } + SmartDialog.show( + tag: "showConfirmDialog", + alignment: Alignment.center, + debounce: true, + animationType: SmartAnimationType.fade, + builder: (_) { + return MsgDialog( + title: SCAppLocalizations.of(context)!.tips, + msg: SCAppLocalizations.of(context)!.theModificationsMade, + onEnsure: () { + SCNavigatorUtils.goBack(context); + }, + ); + }, + ); + } + int _getActualCharacterCount(String text) { return text.characters.length; } @@ -518,6 +703,8 @@ class _RoomEditPageState extends State { final submittedRoomName = _roomNameController.text.trim(); final submittedRoomDesc = _roomAnnouncementController.text.trim(); final submittedRoomCover = roomCover.trim(); + final submittedRoomPassword = _roomLockPasswordController.text.trim(); + final roomId = rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id ?? ""; if (submittedRoomName.isEmpty) { SCTts.show("Room name not empty!"); return; @@ -526,14 +713,21 @@ class _RoomEditPageState extends State { SCTts.show("Room announcement not empty!"); return; } + if (_isRoomPrivate && + (!_initialRoomLocked || submittedRoomPassword.isNotEmpty) && + submittedRoomPassword.isEmpty) { + SCTts.show("Please enter password"); + return; + } SCLoadingManager.show(context: context); var roomInfo = await SCAccountRepository().editRoomInfo( - rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id ?? "", + roomId, submittedRoomCover, submittedRoomName, submittedRoomDesc, SCRoomInfoEventType.AVAILABLE.name, ); + await _submitRoomLockSetting(roomId, submittedRoomPassword); if (!context.mounted) { SCLoadingManager.hide(); return; @@ -594,6 +788,7 @@ class _RoomEditPageState extends State { roomName: mergedRoomInfo.roomName ?? submittedRoomName, roomDesc: mergedRoomInfo.roomDesc ?? submittedRoomDesc, roomSetting: rtcProvider?.currenRoom?.roomProfile?.roomSetting, + existsPassword: _isRoomPrivate, ownerProfile: rtcProvider?.currenRoom?.roomProfile?.userProfile, roomAdminCount: rtcProvider?.currenRoom?.roomProfile?.roomCounter?.adminCount, @@ -616,4 +811,20 @@ class _RoomEditPageState extends State { SCNavigatorUtils.goBack(context); } } + + Future _submitRoomLockSetting(String roomId, String password) async { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + if (_isRoomPrivate) { + if (!_initialRoomLocked || password.isNotEmpty) { + await SCChatRoomRepository().roomLocked(normalizedRoomId, password); + } + } else if (_initialRoomLocked) { + await SCChatRoomRepository().roomUnlocked(normalizedRoomId); + } + _initialRoomLocked = _isRoomPrivate; + _roomLockPasswordController.clear(); + } } diff --git a/lib/modules/room/online/room_online_page.dart b/lib/modules/room/online/room_online_page.dart index 1b14cc4..0c51636 100644 --- a/lib/modules/room/online/room_online_page.dart +++ b/lib/modules/room/online/room_online_page.dart @@ -43,7 +43,7 @@ class _RoomOnlinePageState if (dialogContext == null) { return; } - showBottomInCenterDialog(dialogContext, RoomUserInfoCard(userId: userId)); + showBottomInBottomDialog(dialogContext, RoomUserInfoCard(userId: userId)); }); } diff --git a/lib/modules/room/rank/room_gift_rank_tab_page.dart b/lib/modules/room/rank/room_gift_rank_tab_page.dart index b52901f..36e9f2a 100644 --- a/lib/modules/room/rank/room_gift_rank_tab_page.dart +++ b/lib/modules/room/rank/room_gift_rank_tab_page.dart @@ -98,7 +98,7 @@ class _RoomGiftRankTabPageState // headdress: userInfo.userProfile?.getHeaddress()?.sourceUrl, ), onTap: () { - showBottomInCenterDialog( + showBottomInBottomDialog( navigatorKey.currentState!.context, RoomUserInfoCard(userId: userProfile?.id), ); diff --git a/lib/modules/room/rocket/room_rocket_rank_page.dart b/lib/modules/room/rocket/room_rocket_rank_page.dart index 0ffc19f..b220fef 100644 --- a/lib/modules/room/rocket/room_rocket_rank_page.dart +++ b/lib/modules/room/rocket/room_rocket_rank_page.dart @@ -6,8 +6,13 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; +import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; +import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; +import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; +import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; +import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_widget.dart'; class RoomRocketRankPage extends StatefulWidget { const RoomRocketRankPage({ @@ -66,18 +71,25 @@ class RoomRocketRankEntry { } class _RoomRocketRankPageState extends State { + static const String _effectSuppressReason = 'room_rocket_rank'; + static const int _levelCount = 6; + late int _selectedLevel; final Map> _loadedRanks = {}; final Map _loadedRoundNos = {}; final Map _loadedCurrentUsers = {}; final Set _loadingLevels = {}; - - static const int _levelCount = 6; + RtcProvider? _suppressedRtcProvider; + String _suppressedRoomId = ''; + bool _roomEffectsSuppressed = false; + bool _restoreRoomVisualEffects = false; + bool _restoreVoiceRoomRouteVisible = false; @override void initState() { super.initState(); _selectedLevel = widget.initialLevel.clamp(0, _levelCount - 1); + _suppressRoomEffects(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _loadLevel(_selectedLevel); @@ -85,6 +97,12 @@ class _RoomRocketRankPageState extends State { }); } + @override + void dispose() { + _releaseRoomEffects(); + super.dispose(); + } + @override Widget build(BuildContext context) { return AnnotatedRegion( @@ -110,6 +128,9 @@ class _RoomRocketRankPageState extends State { selectedLevel: _selectedLevel, showRound2Today: widget.showRound2Today, roundNo: _loadedRoundNos[_selectedLevel] ?? 0, + isLoading: + widget.levelRanks == null && + _loadingLevels.contains(_selectedLevel), ranksByLevel: _normalizedRanks, currentUser: widget.currentUser ?? @@ -128,6 +149,61 @@ class _RoomRocketRankPageState extends State { ); } + void _suppressRoomEffects() { + if (_roomEffectsSuppressed) { + return; + } + _roomEffectsSuppressed = true; + try { + final rtcProvider = Provider.of(context, listen: false); + _suppressedRtcProvider = rtcProvider; + _suppressedRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; + _restoreRoomVisualEffects = rtcProvider.roomVisualEffectsEnabled; + _restoreVoiceRoomRouteVisible = rtcProvider.isVoiceRoomRouteVisible; + rtcProvider.setVoiceRoomRouteVisible(false); + rtcProvider.setRoomVisualEffectsEnabled(false); + } catch (_) {} + + RoomEntranceHelper.clearQueue(); + OverlayManager().beginSuppressFloatingScreens( + reason: _effectSuppressReason, + ); + SCRoomEffectScheduler().clearDeferredTasks(reason: _effectSuppressReason); + SCGiftVapSvgaManager().stopPlayback(); + SCGiftVapSvgaManager().pauseAnim(); + } + + void _releaseRoomEffects() { + if (!_roomEffectsSuppressed) { + return; + } + _roomEffectsSuppressed = false; + SCGiftVapSvgaManager().stopPlayback(); + OverlayManager().endSuppressFloatingScreens(reason: _effectSuppressReason); + + final rtcProvider = _suppressedRtcProvider; + if (rtcProvider != null && _isSameSuppressedRoom(rtcProvider)) { + if (_restoreVoiceRoomRouteVisible) { + rtcProvider.setVoiceRoomRouteVisible(true); + } + if (_restoreRoomVisualEffects) { + rtcProvider.setRoomVisualEffectsEnabled(true); + } + } + SCGiftVapSvgaManager().resumeAnim(); + _suppressedRtcProvider = null; + } + + bool _isSameSuppressedRoom(RtcProvider rtcProvider) { + final currentRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; + if (currentRoomId.isEmpty) { + return false; + } + return _suppressedRoomId.isEmpty || currentRoomId == _suppressedRoomId; + } + List> get _normalizedRanks { final providedRanks = widget.levelRanks; if (providedRanks == null) { @@ -178,7 +254,9 @@ class _RoomRocketRankPageState extends State { _rocketRankDebug('skip king request because roomId is empty'); return; } - _loadingLevels.add(index); + setState(() { + _loadingLevels.add(index); + }); _rocketRankDebug('request king roomId=$roomId level=${index + 1}'); SCChatRoomRepository() .roomRocketKing(roomId, level: index + 1) @@ -205,7 +283,13 @@ class _RoomRocketRankPageState extends State { }); }) .whenComplete(() { - _loadingLevels.remove(index); + if (!mounted) { + _loadingLevels.remove(index); + return; + } + setState(() { + _loadingLevels.remove(index); + }); }); } @@ -315,6 +399,7 @@ class _RocketRankCanvas extends StatelessWidget { required this.selectedLevel, required this.showRound2Today, required this.roundNo, + required this.isLoading, required this.ranksByLevel, required this.currentUser, required this.onLevelChanged, @@ -323,6 +408,7 @@ class _RocketRankCanvas extends StatelessWidget { final int selectedLevel; final bool showRound2Today; final int roundNo; + final bool isLoading; final List> ranksByLevel; final RoomRocketRankEntry currentUser; final ValueChanged onLevelChanged; @@ -360,7 +446,9 @@ class _RocketRankCanvas extends StatelessWidget { onChanged: onLevelChanged, ), ), - if (entries.isEmpty) + if (isLoading) + const _RocketRankLoadingState() + else if (entries.isEmpty) const _RocketRankEmptyState() else _RocketRankListContent(entries: entries), @@ -929,6 +1017,23 @@ class _RocketRankBottomBar extends StatelessWidget { } } +class _RocketRankLoadingState extends StatelessWidget { + const _RocketRankLoadingState(); + + @override + Widget build(BuildContext context) { + return const Positioned( + left: 28, + top: 208, + width: 319, + height: 430, + child: Center( + child: SCRotatingDotsLoading(size: 34, color: Color(0xFF75F3CB)), + ), + ); + } +} + class _RocketRankEmptyState extends StatelessWidget { const _RocketRankEmptyState(); @@ -1093,5 +1198,5 @@ class _RocketRankAssets { 'sc_images/room/rocket/rocket_rank_frame_2.png'; static const String rankThreeFrame = 'sc_images/room/rocket/rocket_rank_frame_3.png'; - static const String noData = 'sc_images/general/sc_no_data.png'; + static const String noData = 'sc_images/room/sc_icon_room_music_empty.png'; } diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index d018ffa..942920a 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -209,6 +209,7 @@ class _VoiceRoomPageState extends State void _ensureRoomVisualEffectsEnabled() { final rtcProvider = Provider.of(context, listen: false); if (rtcProvider.currenRoom == null || + !_roomRouteVisible || rtcProvider.roomVisualEffectsEnabled) { return; } diff --git a/lib/modules/search/sc_search_page.dart b/lib/modules/search/sc_search_page.dart index 8cbb05a..fd6c350 100644 --- a/lib/modules/search/sc_search_page.dart +++ b/lib/modules/search/sc_search_page.dart @@ -535,17 +535,17 @@ class _SearchRoomListState extends State { ), ), SizedBox(width: 5.w), - (e.extValues?.roomSetting?.password?.isEmpty ?? false) + e.extValues?.existsPassword != true ? SCRoomLiveAudioIndicator(width: 14.w, height: 14.w) : Image.asset( "sc_images/index/sc_icon_room_suo.png", width: 20.w, height: 20.w, ), - (e.extValues?.roomSetting?.password?.isEmpty ?? false) + e.extValues?.existsPassword != true ? SizedBox(width: 3.w) : Container(height: 10.w), - (e.extValues?.roomSetting?.password?.isEmpty ?? false) + e.extValues?.existsPassword != true ? text( e.displayMemberCount, fontSize: 10.sp, diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index 081879d..2e7b39c 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -29,6 +29,7 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; +import 'package:yumi/ui_kit/widgets/badge/sc_user_badge_strip.dart'; import '../../../app/constants/sc_screen.dart'; import '../../../shared/business_logic/models/res/sc_user_counter_res.dart'; @@ -393,13 +394,20 @@ class _PersonDetailPageState extends State { ); } - Widget _buildHeaderBadgePlaceholder() { - return SizedBox( - height: 24.w, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [], - ), + Widget _buildHeaderBadgeStrip(SocialChatUserProfileManager ref) { + return SCUserBadgeStrip( + userId: ref.userProfile?.id ?? widget.tageId, + fallbackBadges: + ref.userProfile?.wearBadge + ?.where((item) => item.use ?? false) + .toList() ?? + const [], + height: 26.w, + badgeHeight: 24.w, + longBadgeWidth: 58.w, + spacing: 6.w, + maxBadges: 8, + reserveSpace: true, ); } @@ -612,7 +620,7 @@ class _PersonDetailPageState extends State { SizedBox(height: 5.w), _buildHeaderMeta(ref), SizedBox(height: 4.w), - _buildHeaderBadgePlaceholder(), + _buildHeaderBadgeStrip(ref), SizedBox(height: 6.w), _buildHeaderIntroduction(ref), SizedBox(height: 12.w), diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 88598fa..91a4a6c 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -40,6 +40,7 @@ import 'package:yumi/shared/business_logic/models/res/room_res.dart' import 'package:yumi/modules/room/voice_room_route.dart'; import 'package:yumi/ui_kit/widgets/room/empty_mai_select.dart'; import 'package:yumi/ui_kit/widgets/room/room_user_info_card.dart'; +import 'package:yumi/ui_kit/widgets/room/room_password_dialog.dart'; import '../../shared/tools/sc_heartbeat_utils.dart'; import '../../shared/data_sources/models/enum/sc_heartbeat_status.dart'; import '../../shared/data_sources/models/enum/sc_room_info_event_type.dart'; @@ -52,8 +53,8 @@ import '../../shared/business_logic/models/res/sc_room_theme_list_res.dart'; import '../../shared/business_logic/models/res/sc_vip_res.dart'; import '../../shared/tools/sc_room_profile_cache.dart'; import '../../ui_kit/components/sc_float_ichart.dart'; -import '../../ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; -import '../../ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart'; +import '../../ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; +import '../../ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart'; typedef OnSoundVoiceChange = Function(num index, int volum); typedef RtcProvider = RealTimeCommunicationManager; @@ -120,6 +121,7 @@ class RoomEntryPreviewData { this.updateTime, this.activeTime, this.roomSetting, + this.existsPassword, this.ownerProfile, this.roomAdminCount, this.roomMemberCount, @@ -141,6 +143,7 @@ class RoomEntryPreviewData { countryName: room.countryName, nationalFlag: room.nationalFlag, roomSetting: room.extValues?.roomSetting, + existsPassword: room.extValues?.existsPassword == true, ownerProfile: room.userProfile, roomAdminCount: room.roomCounter?.adminCount, roomMemberCount: room.roomCounter?.memberCount, @@ -163,6 +166,7 @@ class RoomEntryPreviewData { countryName: roomProfile?.countryName, nationalFlag: roomProfile?.nationalFlag, roomSetting: roomProfile?.extValues?.roomSetting, + existsPassword: roomProfile?.extValues?.existsPassword == true, ownerProfile: roomProfile?.userProfile, roomAdminCount: roomProfile?.roomCounter?.adminCount, roomMemberCount: roomProfile?.roomCounter?.memberCount, @@ -192,6 +196,7 @@ class RoomEntryPreviewData { updateTime: room.updateTime, activeTime: room.activeTime, roomSetting: room.setting, + existsPassword: (room.setting?.password ?? "").trim().isNotEmpty, ownerProfile: ownerProfile, roomAdminCount: room.counter?.adminCount, roomMemberCount: room.counter?.memberCount, @@ -217,6 +222,7 @@ class RoomEntryPreviewData { final num? updateTime; final num? activeTime; final RoomSetting? roomSetting; + final bool? existsPassword; final SocialChatUserProfile? ownerProfile; final num? roomAdminCount; final num? roomMemberCount; @@ -292,6 +298,9 @@ class _PendingRoomStartupSeatAction { class RealTimeCommunicationManager extends ChangeNotifier { static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog'; + static const String _roomRocketPagLaunchDialogTag = + 'showRoomRocketPagLaunchDialog'; + static const int _roomPasswordNotTrueErrorCode = 9005; static const Duration _micListPollingInterval = Duration(seconds: 2); static const Duration _onlineUsersPollingInterval = Duration(seconds: 3); @@ -2122,6 +2131,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { SCTts.show('Microphone permission is denied.'); throw ArgumentError('Microphone permission is denied.'); } + if (!context.mounted) { + return; + } if (roomId == currenRoom?.roomProfile?.roomProfile?.id) { if (_currentRoomIsEntryPreview || _roomStartupStatus == RoomStartupStatus.loading) { @@ -2164,6 +2176,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { _startVoiceRoomForegroundService(); VoiceRoomRoute.openVoiceRoom(context); } else { + JoinRoomRes? preEnteredRoom; + if (pwd == null && _previewRoomRequiresPassword(context, previewData)) { + preEnteredRoom = await _showLockedRoomEntryDialog( + roomId, + needOpenRedenvelope: needOpenRedenvelope, + redPackId: redPackId, + ); + if (preEnteredRoom == null || !context.mounted) { + return; + } + } if (currenRoom != null) { _switchAwayFromCurrentRoomForNewEntry(); } @@ -2186,15 +2209,26 @@ class RealTimeCommunicationManager extends ChangeNotifier { VoiceRoomRoute.openVoiceRoom(context); notifyListeners(); final entryRequestSerial = ++_roomEntryRequestSerial; - unawaited( - _enterAndInitializeVoiceRoomSession( - roomId, - entryRequestSerial: entryRequestSerial, - pwd: pwd, - needOpenRedenvelope: needOpenRedenvelope, - redPackId: redPackId, - ), - ); + if (preEnteredRoom != null) { + unawaited( + _completeEnteredVoiceRoomSession( + preEnteredRoom, + entryRequestSerial: entryRequestSerial, + needOpenRedenvelope: needOpenRedenvelope, + redPackId: redPackId, + ), + ); + } else { + unawaited( + _enterAndInitializeVoiceRoomSession( + roomId, + entryRequestSerial: entryRequestSerial, + pwd: pwd, + needOpenRedenvelope: needOpenRedenvelope, + redPackId: redPackId, + ), + ); + } } } @@ -2212,32 +2246,167 @@ class RealTimeCommunicationManager extends ChangeNotifier { redPackId: redPackId, needOpenRedenvelope: needOpenRedenvelope, ); - if (!_isActiveRoomEntryRequest(entryRequestSerial)) { - unawaited(_cleanupStaleEnteredRoom(enteredRoom)); - return; - } - currenRoom = _mergeVoiceRoomVipHints(enteredRoom, currenRoom); - _syncCurrentUserVipHintFromRoom(currenRoom); - _startVoiceRoomForegroundService(); - unawaited(_refreshCurrentUserVipHintForRoom(entryRequestSerial)); - _currentRoomIsEntryPreview = false; - _previewRoomSeatCount = null; - notifyListeners(); - await initializeRoomSession( + await _completeEnteredVoiceRoomSession( + enteredRoom, entryRequestSerial: entryRequestSerial, needOpenRedenvelope: needOpenRedenvelope, redPackId: redPackId, - shouldOpenRoomPage: false, ); - } catch (e, stackTrace) { + } catch (e, _) { if (!_isActiveRoomEntryRequest(entryRequestSerial)) { return; } + if (pwd == null && _isRoomPasswordError(e)) { + final enteredRoom = await _showLockedRoomEntryDialog( + roomId, + needOpenRedenvelope: needOpenRedenvelope, + redPackId: redPackId, + ); + if (enteredRoom != null && + _isActiveRoomEntryRequest(entryRequestSerial)) { + await _completeEnteredVoiceRoomSession( + enteredRoom, + entryRequestSerial: entryRequestSerial, + needOpenRedenvelope: needOpenRedenvelope, + redPackId: redPackId, + ); + return; + } + } _stopVoiceRoomForegroundService(); _setRoomStartupFailed(RoomStartupFailureType.entry); } } + Future _completeEnteredVoiceRoomSession( + JoinRoomRes enteredRoom, { + required int entryRequestSerial, + bool needOpenRedenvelope = false, + String redPackId = "", + }) async { + if (!_isActiveRoomEntryRequest(entryRequestSerial)) { + unawaited(_cleanupStaleEnteredRoom(enteredRoom)); + return; + } + currenRoom = _mergeVoiceRoomVipHints(enteredRoom, currenRoom); + _syncCurrentUserVipHintFromRoom(currenRoom); + _startVoiceRoomForegroundService(); + unawaited(_refreshCurrentUserVipHintForRoom(entryRequestSerial)); + _currentRoomIsEntryPreview = false; + _previewRoomSeatCount = null; + notifyListeners(); + await initializeRoomSession( + entryRequestSerial: entryRequestSerial, + needOpenRedenvelope: needOpenRedenvelope, + redPackId: redPackId, + shouldOpenRoomPage: false, + ); + } + + bool _previewRoomRequiresPassword( + BuildContext context, + RoomEntryPreviewData? previewData, + ) { + if (previewData?.existsPassword != true) { + return false; + } + final ownerId = (previewData?.userId ?? "").trim(); + final currentUserId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + if (ownerId.isNotEmpty && ownerId == currentUserId) { + return false; + } + final userIdentity = + Provider.of( + context, + listen: false, + ).userIdentity; + if (userIdentity?.superAdmin == true || + userIdentity?.admin == true || + userIdentity?.yumiManager == true) { + return false; + } + return true; + } + + Future _showLockedRoomEntryDialog( + String roomId, { + bool needOpenRedenvelope = false, + String redPackId = "", + }) async { + JoinRoomRes? enteredRoom; + await RoomPasswordDialog.show( + onSubmit: (password) async { + try { + enteredRoom = await SCAccountRepository().entryRoom( + roomId, + pwd: password, + redPackId: redPackId, + needOpenRedenvelope: needOpenRedenvelope, + silentErrorToast: true, + ); + return null; + } catch (error) { + if (_isRoomPasswordError(error)) { + return "Password is incorrect"; + } + final message = _readNetworkErrorMessage(error); + return message.isNotEmpty ? message : "Failed to enter room"; + } + }, + ); + return enteredRoom; + } + + bool _isRoomPasswordError(Object error) { + return _readNetworkErrorCode(error) == _roomPasswordNotTrueErrorCode || + _readNetworkErrorCodeName(error) == "PASSWORD_NOT_TRUE"; + } + + int? _readNetworkErrorCode(Object error) { + if (error is DioException) { + final responseData = error.response?.data; + if (responseData is Map) { + final value = responseData["errorCode"] ?? responseData["code"]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + return int.tryParse(value); + } + } + } + return null; + } + + String _readNetworkErrorCodeName(Object error) { + if (error is DioException) { + final responseData = error.response?.data; + if (responseData is Map) { + return (responseData["errorCodeName"] ?? responseData["code"] ?? "") + .toString() + .trim(); + } + } + return ""; + } + + String _readNetworkErrorMessage(Object error) { + if (error is DioException) { + final responseData = error.response?.data; + if (responseData is Map) { + return (responseData["errorMsg"] ?? responseData["message"] ?? "") + .toString() + .trim(); + } + return (error.error ?? error.message ?? "").toString().trim(); + } + return error.toString().trim(); + } + JoinRoomRes _mergeVoiceRoomVipHints( JoinRoomRes enteredRoom, JoinRoomRes? previewRoom, @@ -2914,13 +3083,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { maskColor: Colors.transparent, clickMaskDismiss: false, builder: - (_) => RoomRocketRewardDialog( - rewards: RoomRocketApiMapper.rewardItemsFromRecords( - res.records, - ), - winners: RoomRocketApiMapper.winnersFromRecords(res.records), - loadingText: '', - useMockFallback: false, + (_) => RoomRocketRewardDialogLoader( + roomId: roomId, + initialPopupRecords: res.records, onClose: () { SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); if (recordIds.isNotEmpty) { @@ -2971,11 +3136,21 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomRocketLaunchAnimationTimer?.cancel(); if (shouldShowRoomVisualEffects && isVoiceRoomRouteVisible) { - // TODO(rocket-launch): 发射动画素材还没有,资源到位后在这里接入 - // SVGA/VAP/PNG 序列播放。这里先保留完整触发入口和时长控制, - // 避免后续接接口时再改 RTM 分发逻辑。 + final animationUrl = + _firstNonBlankText([ + launch.rocketAnimationUrl, + _rocketLaunchAnimationUrlForLevel(launch.safeLevel), + ]) ?? + ""; + if (animationUrl.isNotEmpty) { + _playRoomRocketLaunchAnimation( + animationUrl: animationUrl, + launch: launch, + ); + } _roomRocketLaunchAnimationTimer = Timer(launch.displayDuration, () { _roomRocketLaunchAnimationTimer = null; + SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); unawaited(refreshRoomRocketStatus(roomId: launch.roomId)); }); return; @@ -2984,6 +3159,68 @@ class RealTimeCommunicationManager extends ChangeNotifier { unawaited(refreshRoomRocketStatus(roomId: launch.roomId)); } + String? _rocketLaunchAnimationUrlForLevel(int level) { + final levels = roomRocketStatus?.levels ?? const []; + for (final item in levels) { + if (item.level == level && item.rocketAnimationUrl.trim().isNotEmpty) { + return item.rocketAnimationUrl; + } + } + return null; + } + + void _playRoomRocketLaunchAnimation({ + required String animationUrl, + required RoomRocketLaunchBroadcastMessage launch, + }) { + if (RoomRocketPagEffectOverlay.isPag(animationUrl)) { + SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); + SmartDialog.show( + tag: _roomRocketPagLaunchDialogTag, + alignment: Alignment.center, + debounce: true, + animationType: SmartAnimationType.fade, + maskColor: Colors.transparent, + clickMaskDismiss: false, + builder: + (_) => RoomRocketPagEffectOverlay( + resource: animationUrl, + topAvatarUrl: _roomRocketTop1AvatarUrl(), + onCompleted: () { + SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); + _roomRocketLaunchAnimationTimer?.cancel(); + _roomRocketLaunchAnimationTimer = null; + unawaited(refreshRoomRocketStatus(roomId: launch.roomId)); + }, + ), + ); + return; + } + SCGiftVapSvgaManager().play( + animationUrl, + priority: SCGiftVapSvgaManager.entryEffectPriority + 100, + type: SCGiftVapSvgaManager.entryEffectType, + ); + } + + String? _roomRocketTop1AvatarUrl() { + final kings = roomRocketStatus?.rocketKings ?? const []; + if (kings.isEmpty) { + return null; + } + for (final item in kings) { + if (item.rank == 1 && item.userAvatar.trim().isNotEmpty) { + return item.userAvatar; + } + } + for (final item in kings) { + if (item.userAvatar.trim().isNotEmpty) { + return item.userAvatar; + } + } + return null; + } + ///获取在线用户 Future fetchOnlineUsersList({bool notifyIfUnchanged = true}) async { final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; @@ -3539,7 +3776,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (clickUser.id == AccountStorage().getCurrentUser()?.userProfile?.id) { ///是自己,直接打开资料卡 - showBottomInCenterDialog( + showBottomInBottomDialog( context!, RoomUserInfoCard(userId: clickUser.id), ); @@ -3649,7 +3886,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (normalizedUserId.isEmpty) { return; } - showBottomInCenterDialog( + showBottomInBottomDialog( context!, RoomUserInfoCard(userId: normalizedUserId), ); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 2cc9ef8..6770751 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -54,14 +54,14 @@ import 'package:yumi/shared/business_logic/models/res/broad_cast_mic_change_push hide Data; import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_public_message_page_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_theme_list_res.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/ui_kit/widgets/room/invite/invite_room_dialog.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_open_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart'; -import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; -import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/services/gift/gift_system_manager.dart'; @@ -119,6 +119,7 @@ class RealTimeMessagingManager extends ChangeNotifier { static const int _luckyGiftBurstPlaybackWatchdogMs = 8000; BuildContext? context; + final Set _sentRoomRocketRewardMessageKeys = {}; void _giftFxLog(String message) {} @@ -1103,6 +1104,8 @@ class RealTimeMessagingManager extends ChangeNotifier { return true; case SCRoomMsgType.roomRedPacketClaim: return true; + case SCRoomMsgType.rocketRewardUser: + return true; case SCRoomMsgType.emoticons: return (msg.number ?? -1) < 0; default: @@ -1132,6 +1135,10 @@ class RealTimeMessagingManager extends ChangeNotifier { _hasRoomRedPacketClaimMessage(msg)) { return; } + if (msg.type == SCRoomMsgType.rocketRewardUser && + _hasRoomRocketRewardMessage(msg)) { + return; + } roomAllMsgList.insert(0, msg); if (roomAllMsgList.length > 250) { @@ -1164,6 +1171,12 @@ class RealTimeMessagingManager extends ChangeNotifier { roomChatMsgList.removeAt(roomChatMsgList.length - 1); } msgChatListener?.call(msg); + } else if (msg.type == SCRoomMsgType.rocketRewardUser) { + roomChatMsgList.insert(0, msg); + if (roomChatMsgList.length > 250) { + roomChatMsgList.removeAt(roomChatMsgList.length - 1); + } + msgChatListener?.call(msg); } else if (msg.type == SCRoomMsgType.gift || msg.type == SCRoomMsgType.luckGiftAnimOther) { roomGiftMsgList.insert(0, msg); @@ -1271,6 +1284,32 @@ class RealTimeMessagingManager extends ChangeNotifier { return ['legacy', userId, msg.msg ?? '', amount, time].join('|'); } + bool _hasRoomRocketRewardMessage(Msg msg) { + final key = _roomRocketRewardMessageKey(msg); + if (key.isEmpty) { + return false; + } + return roomAllMsgList.any( + (item) => + item.type == SCRoomMsgType.rocketRewardUser && + _roomRocketRewardMessageKey(item) == key, + ); + } + + String _roomRocketRewardMessageKey(Msg msg) { + final stableKey = (msg.giftBatchId ?? '').trim(); + if (stableKey.isNotEmpty) { + return stableKey; + } + return [ + msg.groupId ?? '', + msg.user?.id ?? '', + msg.number?.toString() ?? '', + msg.role ?? '', + msg.msg ?? '', + ].join('|'); + } + Msg? _mergeGiftMessageIfNeeded(Msg incoming) { if (incoming.type != SCRoomMsgType.gift && incoming.type != SCRoomMsgType.luckGiftAnimOther) { @@ -1825,7 +1864,6 @@ class RealTimeMessagingManager extends ChangeNotifier { (rtcProvider.currenRoom?.roomProfile?.roomProfile?.id ?? '').trim(); if (currentRoomId == launch.roomId) { rtcProvider.handleRoomRocketLaunchBroadcast(launch); - return; } } @@ -1835,19 +1873,259 @@ class RealTimeMessagingManager extends ChangeNotifier { void _handleVoiceRoomRocketRewardPopup(dynamic payload) { final data = _broadcastPayloadMap(payload); final roomId = _payloadText(data['roomId']); + final currentRoomId = _currentVoiceRoomId(); + final resolvedRoomId = roomId.isEmpty ? currentRoomId : roomId; + if (resolvedRoomId.isEmpty || + (currentRoomId.isNotEmpty && resolvedRoomId != currentRoomId)) { + return; + } + final userIds = _payloadStringList(data['userIds']); final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? ''; if (currentUserId.isEmpty || !userIds.contains(currentUserId)) { return; } - final currentRoomId = _currentVoiceRoomId(); - if (roomId.isNotEmpty && - currentRoomId.isNotEmpty && - roomId != currentRoomId) { + unawaited(_showRoomRocketRewardPopupDialog(resolvedRoomId)); + } + + Future _sendRoomRocketRewardPublicMessages( + String roomId, + Map payload, + ) async { + final groupId = _currentVoiceRoomGroupId(); + if (groupId.isEmpty) { return; } - unawaited(_showRoomRocketRewardPopupDialog(roomId)); + final launchNo = _payloadText(payload['launchNo']); + var records = _rocketRewardRecordsFromPayload(payload); + if (records.isEmpty) { + records = await _loadRocketRewardPublicRecords(roomId, launchNo); + } + if (records.isNotEmpty) { + for (final record in records) { + await _sendRoomRocketRewardMessage( + groupId, + _buildRocketRewardRecordMsg(groupId, record), + ); + } + return; + } + + final userIds = _payloadStringList(payload['userIds']); + for (final userId in userIds) { + await _sendRoomRocketRewardMessage( + groupId, + _buildRocketRewardFallbackMsg(groupId, payload, userId), + ); + } + } + + Future _sendRoomRocketRewardMessage(String groupId, Msg msg) async { + final key = _roomRocketRewardMessageKey(msg); + if (key.isEmpty || !_sentRoomRocketRewardMessageKeys.add(key)) { + return; + } + try { + final textMsg = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createCustomMessage(data: jsonEncode(msg.toJson())); + if (textMsg.code != 0 || textMsg.data?.id == null) { + _sentRoomRocketRewardMessageKeys.remove(key); + return; + } + final sendResult = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .sendMessage(id: textMsg.data!.id!, groupID: groupId, receiver: ''); + if (sendResult.code == 0) { + addMsg(msg); + } else { + _sentRoomRocketRewardMessageKeys.remove(key); + } + } catch (_) { + _sentRoomRocketRewardMessageKeys.remove(key); + } + } + + Future> _loadRocketRewardPublicRecords( + String roomId, + String launchNo, + ) async { + if (roomId.trim().isEmpty || launchNo.trim().isEmpty) { + return const []; + } + try { + final res = await SCChatRoomRepository().roomRocketRewardRecords( + roomId, + cursor: 1, + limit: 100, + ); + return res.records + .where((item) => item.launchNo.trim() == launchNo.trim()) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + List _rocketRewardRecordsFromPayload( + Map payload, + ) { + final candidates = [ + payload['records'], + payload['rewardRecords'], + payload['rewards'], + _broadcastPayloadMap(payload['body'])['records'], + _broadcastPayloadMap(payload['data'])['records'], + ]; + for (final candidate in candidates) { + if (candidate is Iterable) { + final records = candidate + .map(SCRoomRocketRewardRecordRes.fromJson) + .toList(growable: false); + final usableRecords = records + .where((item) => item.userId.trim().isNotEmpty) + .toList(growable: false); + if (usableRecords.isNotEmpty) { + return usableRecords; + } + } + } + return const []; + } + + Msg _buildRocketRewardRecordMsg( + String groupId, + SCRoomRocketRewardRecordRes record, + ) { + return Msg( + groupId: groupId, + msg: record.rewardCover, + type: SCRoomMsgType.rocketRewardUser, + user: SocialChatUserProfile( + id: record.userId, + userNickname: record.displayName, + userAvatar: record.userAvatar, + account: record.account, + ), + role: _rocketRewardRole(record.rewardType, record.rewardCover), + number: record.level <= 0 ? 1 : record.level, + giftBatchId: _rocketRewardRecordKey(record), + ); + } + + Msg _buildRocketRewardFallbackMsg( + String groupId, + Map payload, + String userId, + ) { + final rewardCover = _firstNonBlank([ + _payloadText(payload['rewardCover']), + _payloadText(payload['rewardIcon']), + _payloadText(payload['rewardUrl']), + _payloadText(payload['giftUrl']), + _payloadText(payload['giftIcon']), + ]); + final rewardType = _firstNonBlank([ + _payloadText(payload['rewardType']), + _payloadText(payload['type']), + rewardCover.isEmpty ? 'GOLD' : '', + ]); + final level = (_payloadNum(payload['level']) ?? 1).toInt(); + return Msg( + groupId: groupId, + msg: rewardCover, + type: SCRoomMsgType.rocketRewardUser, + user: _rocketRewardUserFromPayload(payload, userId), + role: _rocketRewardRole(rewardType, rewardCover), + number: level <= 0 ? 1 : level, + giftBatchId: [ + 'rocket_reward_fallback', + _payloadText(payload['launchNo']), + userId, + level, + rewardType, + ].join('|'), + ); + } + + SocialChatUserProfile _rocketRewardUserFromPayload( + Map payload, + String userId, + ) { + final userPayload = _rocketRewardUserPayload(payload, userId); + final nickname = _firstNonBlank([ + _payloadText(userPayload['userNickname']), + _payloadText(userPayload['nickname']), + _payloadText(userPayload['nickName']), + _payloadText(userPayload['account']), + userId, + ]); + return SocialChatUserProfile( + id: _firstNonBlank([_payloadText(userPayload['userId']), userId]), + userNickname: nickname, + userAvatar: _firstNonBlank([ + _payloadText(userPayload['userAvatar']), + _payloadText(userPayload['avatar']), + _payloadText(userPayload['avatarUrl']), + ]), + account: _payloadText(userPayload['account']), + ); + } + + Map _rocketRewardUserPayload( + Map payload, + String userId, + ) { + final candidates = [ + payload['users'], + payload['rewardUsers'], + payload['winnerUsers'], + payload['winners'], + ]; + for (final candidate in candidates) { + if (candidate is Map) { + final item = candidate[userId] ?? candidate[userId.toString()]; + final map = _broadcastPayloadMap(item); + if (map.isNotEmpty) { + return map; + } + } + if (candidate is Iterable) { + for (final item in candidate) { + final map = _broadcastPayloadMap(item); + final id = _firstNonBlank([ + _payloadText(map['userId']), + _payloadText(map['id']), + _payloadText(map['account']), + ]); + if (id == userId) { + return map; + } + } + } + } + return {'userId': userId, 'userNickname': userId}; + } + + String _rocketRewardRole(String rewardType, String rewardCover) { + final normalized = rewardType.trim().toUpperCase(); + if (normalized == 'GOLD' || normalized == 'COIN' || normalized == 'COINS') { + return 'GOLD'; + } + return rewardCover.trim().isEmpty ? 'GOLD' : normalized; + } + + String _rocketRewardRecordKey(SCRoomRocketRewardRecordRes record) { + return [ + 'rocket_reward', + record.id, + record.launchNo, + record.userId, + record.rewardScene, + record.rewardType, + record.rewardItemId, + ].join('|'); } Future _showRoomRocketRewardPopupDialog(String roomId) async { @@ -1856,6 +2134,11 @@ class RealTimeMessagingManager extends ChangeNotifier { if (res.records.isEmpty) { return; } + unawaited( + _sendRoomRocketRewardPublicMessages(roomId, { + 'records': res.records.map((item) => item.toJson()).toList(), + }), + ); final recordIds = res.records .map((item) => item.id) .where((item) => item.isNotEmpty) @@ -1869,11 +2152,9 @@ class RealTimeMessagingManager extends ChangeNotifier { maskColor: Colors.transparent, clickMaskDismiss: false, builder: - (_) => RoomRocketRewardDialog( - rewards: RoomRocketApiMapper.rewardItemsFromRecords(res.records), - winners: RoomRocketApiMapper.winnersFromRecords(res.records), - loadingText: '', - useMockFallback: false, + (_) => RoomRocketRewardDialogLoader( + roomId: roomId, + initialPopupRecords: res.records, onClose: () { SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); if (recordIds.isNotEmpty) { @@ -1894,13 +2175,18 @@ class RealTimeMessagingManager extends ChangeNotifier { type: 3, roomId: launch.roomId, rocketLevel: launch.safeLevel, - userAvatarUrl: launch.triggerUser.avatar, - userName: launch.triggerUser.nickname, + userAvatarUrl: _firstNonBlank([ + launch.roomCoverUrl, + launch.triggerUser.avatar, + ]), + userName: _firstNonBlank([launch.roomName, launch.roomAccount]), userId: _firstNonBlank([ launch.triggerUser.account, launch.triggerUser.userId, launch.triggerUserId, ]), + giftUrl: launch.rocketIconUrl, + fallbackUrl: launch.rocketIconUrl, giftId: launch.launchNo, durationSeconds: launch.durationSeconds, priority: 1000, @@ -2074,13 +2360,35 @@ class RealTimeMessagingManager extends ChangeNotifier { } else if (type == "ROCKET_ENERGY_LAUNCH") { ///火箭触发飘屏 var fdata = data["data"]; + final rocketIconUrl = _firstNonBlank([ + _payloadText(fdata["rocketIconUrl"]), + _payloadText(fdata["rocketIcon"]), + _payloadText(fdata["rocketUrl"]), + _payloadText(fdata["rocketImageUrl"]), + _payloadText(fdata["giftUrl"]), + _payloadText(fdata["giftIcon"]), + ]); SCFloatingMessage msg = SCFloatingMessage( type: 3, roomId: fdata["roomId"], rocketLevel: fdata["fromLevel"], - userAvatarUrl: fdata["userAvatar"], - userName: fdata["nickname"], + userAvatarUrl: _firstNonBlank([ + _payloadText(fdata["roomCoverUrl"]), + _payloadText(fdata["roomCover"]), + _payloadText(fdata["roomAvatar"]), + _payloadText(fdata["roomIcon"]), + _payloadText(fdata["coverUrl"]), + _payloadText(fdata["cover"]), + _payloadText(fdata["userAvatar"]), + ]), + userName: _firstNonBlank([ + _payloadText(fdata["roomName"]), + _payloadText(fdata["roomAccount"]), + _payloadText(fdata["nickname"]), + ]), userId: fdata["actualAccount"], + giftUrl: rocketIconUrl, + fallbackUrl: rocketIconUrl, priority: 1000, ); OverlayManager().addMessage(msg); diff --git a/lib/services/general/sc_app_general_manager.dart b/lib/services/general/sc_app_general_manager.dart index e229272..bd69f00 100644 --- a/lib/services/general/sc_app_general_manager.dart +++ b/lib/services/general/sc_app_general_manager.dart @@ -9,6 +9,7 @@ import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/shared/data_sources/models/country_mode.dart'; import 'package:yumi/shared/business_logic/models/res/sc_banner_leaderboard_res.dart'; import 'package:yumi/shared/business_logic/models/res/country_res.dart'; +import 'package:yumi/shared/business_logic/models/res/gift_backpack_res.dart'; import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_emoji_res.dart'; @@ -17,6 +18,7 @@ import '../../shared/data_sources/models/enum/sc_banner_type.dart'; import '../../shared/data_sources/models/enum/sc_gift_type.dart'; class SCAppGeneralManager extends ChangeNotifier { + static const String backpackGiftTab = 'backpack'; static const int _maxInitialFullScreenGiftPreloads = 4; List countryModeList = []; @@ -32,9 +34,12 @@ class SCAppGeneralManager extends ChangeNotifier { final Set _warmingGiftCoverUrls = {}; bool _isFetchingGiftList = false; bool _hasGiftListLoaded = false; + bool _isFetchingGiftBackpack = false; + bool _hasGiftBackpackLoaded = false; Map> giftByTab = {}; Map> activityGiftByTab = {}; + List giftBackpackResList = []; ///选中的国家 Country? selectCountryInfo; @@ -51,6 +56,13 @@ class SCAppGeneralManager extends ChangeNotifier { bool get isGiftListLoading => _isFetchingGiftList && !_hasGiftListLoaded; + bool get isGiftBackpackLoading => + _isFetchingGiftBackpack && !_hasGiftBackpackLoaded; + + bool isGiftTabLoading(String type) { + return type == backpackGiftTab ? isGiftBackpackLoading : isGiftListLoading; + } + ///加载国家列表 Future fetchCountryList() async { if (_isFetchingCountryList) { @@ -203,6 +215,7 @@ class SCAppGeneralManager extends ChangeNotifier { } else { giftByTab.remove("CUSTOMIZED"); } + _mergeBackpackGiftTab(); } Future warmGiftPageCovers( @@ -270,12 +283,75 @@ class SCAppGeneralManager extends ChangeNotifier { } ///礼物背包 - void giftBackpack() async { - try { - await SCChatRoomRepository().giftBackpack(); - } catch (_) { + Future giftBackpack({bool forceRefresh = false}) async { + if (_isFetchingGiftBackpack) { return; } + if (_hasGiftBackpackLoaded && !forceRefresh) { + _mergeBackpackGiftTab(); + notifyListeners(); + unawaited( + warmGiftPageCovers( + backpackGiftTab, + pageIndex: 0, + pageCount: 2, + pageSize: 8, + ), + ); + return; + } + _isFetchingGiftBackpack = true; + if (!_hasGiftBackpackLoaded) { + notifyListeners(); + } + try { + giftBackpackResList = await SCChatRoomRepository().giftBackpack(); + _hasGiftBackpackLoaded = true; + _mergeBackpackGiftTab(); + notifyListeners(); + unawaited( + warmGiftPageCovers( + backpackGiftTab, + pageIndex: 0, + pageCount: 2, + pageSize: 8, + ), + ); + } catch (_) { + return; + } finally { + _isFetchingGiftBackpack = false; + notifyListeners(); + } + } + + void _mergeBackpackGiftTab() { + final backpackGifts = []; + for (final item in giftBackpackResList) { + final giftConfig = item.giftConfig; + if (giftConfig == null) { + continue; + } + backpackGifts.add( + giftConfig.copyWith( + giftTab: backpackGiftTab, + quantity: _formatBackpackQuantity(item.quantity), + backpackId: item.id, + backpackQuantity: item.quantity, + ), + ); + } + giftByTab[backpackGiftTab] = backpackGifts; + } + + String _formatBackpackQuantity(num? quantity) { + if (quantity == null) { + return '0'; + } + if (quantity % 1 == 0) { + return quantity.toInt().toString(); + } + return quantity.toString(); } ///礼物id获取礼物 diff --git a/lib/shared/business_logic/models/res/gift_res.dart b/lib/shared/business_logic/models/res/gift_res.dart index 2cef234..ae63b1d 100644 --- a/lib/shared/business_logic/models/res/gift_res.dart +++ b/lib/shared/business_logic/models/res/gift_res.dart @@ -34,6 +34,8 @@ class SocialChatGiftRes { String? userId, String? giftName, String? quantity, + String? backpackId, + num? backpackQuantity, num? expiredTime, }) { _id = id; @@ -54,6 +56,8 @@ class SocialChatGiftRes { _userId = userId; _giftName = giftName; _quantity = quantity; + _backpackId = backpackId; + _backpackQuantity = backpackQuantity; _expiredTime = expiredTime; } @@ -123,6 +127,12 @@ class SocialChatGiftRes { _quantity = _giftResString( json['quantity'] ?? json['giftQuantity'] ?? json['count'] ?? json['num'], ); + _backpackId = _giftResString( + json['backpackId'] ?? json['backId'] ?? json['backpackRecordId'], + ); + _backpackQuantity = _giftResNum( + json['backpackQuantity'] ?? json['backpackCount'], + ); _expiredTime = _giftResNum( json['expiredTime'] ?? giftConfig?['expiredTime'], ); @@ -145,6 +155,8 @@ class SocialChatGiftRes { String? _userId; String? _giftName; String? _quantity; + String? _backpackId; + num? _backpackQuantity; num? _expiredTime; SocialChatGiftRes copyWith({ String? id, @@ -165,6 +177,8 @@ class SocialChatGiftRes { String? userId, String? giftName, String? quantity, + String? backpackId, + num? backpackQuantity, num? expiredTime, }) => SocialChatGiftRes( id: id ?? _id, @@ -185,6 +199,8 @@ class SocialChatGiftRes { userId: userId ?? _userId, giftName: giftName ?? _giftName, quantity: quantity ?? _quantity, + backpackId: backpackId ?? _backpackId, + backpackQuantity: backpackQuantity ?? _backpackQuantity, expiredTime: expiredTime ?? _expiredTime, ); String? get id => _id; @@ -205,6 +221,8 @@ class SocialChatGiftRes { String? get userId => _userId; String? get giftName => _giftName; String? get quantity => _quantity; + String? get backpackId => _backpackId; + num? get backpackQuantity => _backpackQuantity; num? get expiredTime => _expiredTime; Map toJson() { @@ -227,6 +245,8 @@ class SocialChatGiftRes { map['userId'] = _userId; map['giftName'] = _giftName; map['quantity'] = _quantity; + map['backpackId'] = _backpackId; + map['backpackQuantity'] = _backpackQuantity; map['expiredTime'] = _expiredTime; return map; } diff --git a/lib/shared/business_logic/models/res/login_res.dart b/lib/shared/business_logic/models/res/login_res.dart index 3718284..9109c50 100644 --- a/lib/shared/business_logic/models/res/login_res.dart +++ b/lib/shared/business_logic/models/res/login_res.dart @@ -878,20 +878,30 @@ class WearBadge { _userId = userId; } - WearBadge.fromJson(dynamic json) { - _animationUrl = json['animationUrl']; - _badgeKey = json['badgeKey']; - _badgeLevel = json['badgeLevel']; - _badgeName = json['badgeName']; - _expireTime = json['expireTime']; - _id = json['id']; - _use = json['use']; - _milestone = json['milestone']; - _notSelectUrl = json['notSelectUrl']; - _selectUrl = json['selectUrl']; - _type = json['type']; - _userId = json['userId']; - } + WearBadge.fromJson(dynamic json) { + if (json is! Map) { + return; + } + _animationUrl = _stringValue(json['animationUrl']); + _badgeKey = _stringValue(json['badgeKey']); + _badgeLevel = _numValue(json['badgeLevel'] ?? json['level']); + _badgeName = _stringValue( + json['badgeName'] ?? json['name'] ?? json['displayName'], + ); + _expireTime = _intValue(json['expireTime'] ?? json['expireAt']); + _id = _stringValue(json['id'] ?? json['badgeId']); + _use = _boolValue(json['use']); + _milestone = _stringValue(json['milestone']); + _notSelectUrl = _stringValue(json['notSelectUrl']); + _selectUrl = _stringValue( + json['url'] ?? + json['selectUrl'] ?? + json['notSelectUrl'] ?? + json['animationUrl'], + ); + _type = _stringValue(json['type']); + _userId = _stringValue(json['userId']); + } String? _animationUrl; String? _badgeKey; @@ -958,7 +968,7 @@ class WearBadge { String? get userId => _userId; - Map toJson() { + Map toJson() { final map = {}; map['animationUrl'] = _animationUrl; map['badgeKey'] = _badgeKey; @@ -972,9 +982,48 @@ class WearBadge { map['selectUrl'] = _selectUrl; map['type'] = _type; map['userId'] = _userId; - return map; - } -} + return map; + } + + static String? _stringValue(dynamic value) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? null : text; + } + + static int? _intValue(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ""); + } + + static num? _numValue(dynamic value) { + if (value is num) { + return value; + } + return num.tryParse(value?.toString() ?? ""); + } + + static bool? _boolValue(dynamic value) { + if (value is bool) { + return value; + } + if (value is num) { + return value != 0; + } + final text = value?.toString().trim().toLowerCase(); + if (text == "true" || text == "1") { + return true; + } + if (text == "false" || text == "0") { + return false; + } + return null; + } +} class PersonPhoto { PersonPhoto({String? url, num? status}) { diff --git a/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart index c0a0fa7..5c70817 100644 --- a/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart +++ b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart @@ -4,21 +4,192 @@ class SCRoomRocketLevelRes { required this.level, required this.needEnergy, required this.rocketIconUrl, + required this.rocketPreviewUrl, required this.rocketAnimationUrl, required this.progressBarUrl, + required this.shakeThresholdPercent, required this.enabled, required this.sort, }); factory SCRoomRocketLevelRes.fromJson(dynamic json) { final data = _asMap(json); + final rocket = _asMap(data['rocket']); + final levelInfo = _asMap(data['levelInfo']); + final config = _asMap(data['config']); + final resource = _asMap( + data['resource'] ?? data['propsResources'] ?? data['rocketResource'], + ); + final preview = _asMap( + data['rocketPreview'] ?? + data['preview'] ?? + data['rocketPreviewResource'] ?? + data['previewResource'], + ); + final animation = _asMap( + data['rocketAnimation'] ?? + data['animation'] ?? + data['rocketAnimationResource'] ?? + data['animationResource'] ?? + data['launchAnimation'], + ); return SCRoomRocketLevelRes( id: _stringValue(data['id']), level: _intValue(data['level']), needEnergy: _intValue(data['needEnergy']), - rocketIconUrl: _stringValue(data['rocketIconUrl']), - rocketAnimationUrl: _stringValue(data['rocketAnimationUrl']), - progressBarUrl: _stringValue(data['progressBarUrl']), + rocketIconUrl: _firstAssetString([ + data['rocket_icon_url'], + data['rocketIconUrl'], + data['iconUrl'], + data['icon'], + data['rocketIcon'], + rocket['rocket_icon_url'], + rocket['rocketIconUrl'], + rocket['iconUrl'], + rocket['icon'], + rocket['rocketIcon'], + levelInfo['rocket_icon_url'], + levelInfo['rocketIconUrl'], + levelInfo['iconUrl'], + levelInfo['rocketIcon'], + config['rocket_icon_url'], + config['rocketIconUrl'], + config['iconUrl'], + config['rocketIcon'], + resource['rocket_icon_url'], + resource['rocketIconUrl'], + resource['iconUrl'], + resource['cover'], + resource['sourceUrl'], + ]), + rocketPreviewUrl: _firstAssetString([ + data['preview_rocket_url'], + data['previewRocketUrl'], + data['rocketPreviewUrl'], + data['rocketPreviewSvgUrl'], + data['rocketPreviewSvg'], + data['rocketPreview'], + data['rocketSvgUrl'], + data['rocketSvg'], + data['previewSvgUrl'], + data['previewSvg'], + data['previewUrl'], + data['preview'], + data['svgUrl'], + data['svg'], + data['rocketDisplayUrl'], + data['rocketShowUrl'], + data['rocketImageUrl'], + data['previewImageUrl'], + data['sourceUrl'], + rocket['preview_rocket_url'], + rocket['previewRocketUrl'], + rocket['rocketPreviewUrl'], + rocket['rocketPreviewSvgUrl'], + rocket['rocketPreviewSvg'], + rocket['rocketPreview'], + rocket['rocketSvgUrl'], + rocket['rocketSvg'], + rocket['previewSvgUrl'], + rocket['previewSvg'], + rocket['previewUrl'], + rocket['preview'], + rocket['svgUrl'], + rocket['sourceUrl'], + levelInfo['preview_rocket_url'], + levelInfo['previewRocketUrl'], + levelInfo['rocketPreviewUrl'], + levelInfo['rocketPreviewSvgUrl'], + levelInfo['rocketPreviewSvg'], + levelInfo['rocketPreview'], + levelInfo['previewUrl'], + levelInfo['previewSvgUrl'], + levelInfo['previewSvg'], + levelInfo['preview'], + levelInfo['svgUrl'], + levelInfo['sourceUrl'], + config['preview_rocket_url'], + config['previewRocketUrl'], + config['rocketPreviewUrl'], + config['rocketPreviewSvgUrl'], + config['rocketPreviewSvg'], + config['rocketPreview'], + config['previewUrl'], + config['previewSvgUrl'], + config['previewSvg'], + config['preview'], + config['svgUrl'], + config['sourceUrl'], + preview, + resource['preview_rocket_url'], + resource['previewRocketUrl'], + resource['rocketPreviewUrl'], + resource['previewUrl'], + resource['sourceUrl'], + ]), + rocketAnimationUrl: _firstAssetString([ + data['rocket_animation_url'], + data['rocketAnimationUrl'], + data['rocketSvgaUrl'], + data['animationUrl'], + data['rocketAnimation'], + data['animation'], + data['svgaUrl'], + data['rocketLaunchSvgaUrl'], + data['launchAnimationUrl'], + data['launchAnimation'], + data['launchSvgaUrl'], + data['sourceUrl'], + rocket['rocket_animation_url'], + rocket['rocketAnimationUrl'], + rocket['rocketSvgaUrl'], + rocket['animationUrl'], + rocket['rocketAnimation'], + rocket['animation'], + rocket['svgaUrl'], + rocket['sourceUrl'], + levelInfo['rocket_animation_url'], + levelInfo['rocketAnimationUrl'], + levelInfo['rocketSvgaUrl'], + levelInfo['animationUrl'], + levelInfo['rocketAnimation'], + levelInfo['animation'], + levelInfo['svgaUrl'], + levelInfo['sourceUrl'], + config['rocket_animation_url'], + config['rocketAnimationUrl'], + config['rocketSvgaUrl'], + config['animationUrl'], + config['rocketAnimation'], + config['animation'], + config['svgaUrl'], + config['sourceUrl'], + animation, + ]), + progressBarUrl: _firstAssetString([ + data['progress_bar_url'], + data['progressBarUrl'], + data['progressUrl'], + data['progressBar'], + rocket['progress_bar_url'], + rocket['progressBarUrl'], + levelInfo['progress_bar_url'], + levelInfo['progressBarUrl'], + config['progress_bar_url'], + config['progressBarUrl'], + ]), + shakeThresholdPercent: + _numValue( + data['shake_threshold_percent'] ?? + data['shakeThresholdPercent'] ?? + rocket['shake_threshold_percent'] ?? + rocket['shakeThresholdPercent'] ?? + levelInfo['shake_threshold_percent'] ?? + levelInfo['shakeThresholdPercent'] ?? + config['shake_threshold_percent'] ?? + config['shakeThresholdPercent'], + ) ?? + 95, enabled: _boolValue(data['enabled'], fallback: true), sort: _intValue(data['sort']), ); @@ -28,10 +199,27 @@ class SCRoomRocketLevelRes { final int level; final int needEnergy; final String rocketIconUrl; + final String rocketPreviewUrl; final String rocketAnimationUrl; final String progressBarUrl; + final num shakeThresholdPercent; final bool enabled; final int sort; + + Map toJson() { + return { + 'id': id, + 'level': level, + 'needEnergy': needEnergy, + 'rocketIconUrl': rocketIconUrl, + 'rocketPreviewUrl': rocketPreviewUrl, + 'rocketAnimationUrl': rocketAnimationUrl, + 'progressBarUrl': progressBarUrl, + 'shakeThresholdPercent': shakeThresholdPercent, + 'enabled': enabled, + 'sort': sort, + }; + } } class SCRoomRocketRewardRes { @@ -230,16 +418,36 @@ class SCRoomRocketKingRes { } class SCRoomRocketRewardPopupRes { - SCRoomRocketRewardPopupRes({required this.records}); + SCRoomRocketRewardPopupRes({ + required this.records, + required this.cursor, + required this.limit, + required this.emptyReason, + }); factory SCRoomRocketRewardPopupRes.fromJson(dynamic json) { final data = _asMap(json); return SCRoomRocketRewardPopupRes( records: SCRoomRocketRewardRecordRes.listFromJson(data['records']), + cursor: _intValue(data['cursor'], fallback: 1), + limit: _intValue(data['limit'], fallback: 10), + emptyReason: _stringValue(data['emptyReason']), ); } final List records; + final int cursor; + final int limit; + final String emptyReason; + + Map toJson() { + return { + "records": records.map((item) => item.toJson()).toList(growable: false), + "cursor": cursor, + "limit": limit, + "emptyReason": emptyReason, + }; + } } class SCRoomRocketRewardRecordsRes { @@ -255,7 +463,7 @@ class SCRoomRocketRewardRecordsRes { return SCRoomRocketRewardRecordsRes( records: SCRoomRocketRewardRecordRes.listFromJson(data['records']), cursor: _intValue(data['cursor'], fallback: 1), - limit: _intValue(data['limit'], fallback: 20), + limit: _intValue(data['limit'], fallback: 10), emptyReason: _stringValue(data['emptyReason']), ); } @@ -264,6 +472,15 @@ class SCRoomRocketRewardRecordsRes { final int cursor; final int limit; final String emptyReason; + + Map toJson() { + return { + "records": records.map((item) => item.toJson()).toList(growable: false), + "cursor": cursor, + "limit": limit, + "emptyReason": emptyReason, + }; + } } class SCRoomRocketRewardRecordRes { @@ -275,6 +492,9 @@ class SCRoomRocketRewardRecordRes { required this.roundNo, required this.level, required this.userId, + required this.userAvatar, + required this.userNickname, + required this.account, required this.rewardScene, required this.rewardType, required this.rewardItemId, @@ -299,6 +519,25 @@ class SCRoomRocketRewardRecordRes { roundNo: _intValue(data['roundNo']), level: _intValue(data['level']), userId: _stringValue(data['userId']), + userAvatar: _stringValue( + data['userAvatar'] ?? + data['avatar'] ?? + data['avatarUrl'] ?? + _asMap(data['userProfile'] ?? data['user'])['userAvatar'] ?? + _asMap(data['userProfile'] ?? data['user'])['avatar'], + ), + userNickname: _stringValue( + data['userNickname'] ?? + data['nickname'] ?? + data['nickName'] ?? + _asMap(data['userProfile'] ?? data['user'])['userNickname'] ?? + _asMap(data['userProfile'] ?? data['user'])['nickname'] ?? + _asMap(data['userProfile'] ?? data['user'])['nickName'], + ), + account: _stringValue( + data['account'] ?? + _asMap(data['userProfile'] ?? data['user'])['account'], + ), rewardScene: _stringValue(data['rewardScene']), rewardType: _stringValue(data['rewardType']), rewardItemId: _stringValue(data['rewardItemId']), @@ -321,6 +560,9 @@ class SCRoomRocketRewardRecordRes { final int roundNo; final int level; final String userId; + final String userAvatar; + final String userNickname; + final String account; final String rewardScene; final String rewardType; final String rewardItemId; @@ -334,6 +576,19 @@ class SCRoomRocketRewardRecordRes { final String createTime; final String errorMessage; + String get displayName { + if (userNickname.trim().isNotEmpty) { + return userNickname; + } + if (account.trim().isNotEmpty) { + return account; + } + if (userId.trim().isNotEmpty) { + return 'ID:$userId'; + } + return 'You'; + } + String get displayAmount { if (rewardAmount <= 0) { return '1'; @@ -344,6 +599,33 @@ class SCRoomRocketRewardRecordRes { return rewardAmount.toString(); } + Map toJson() { + return { + "id": id, + "launchNo": launchNo, + "roomId": roomId, + "dayKey": dayKey, + "roundNo": roundNo, + "level": level, + "userId": userId, + "userAvatar": userAvatar, + "userNickname": userNickname, + "account": account, + "rewardScene": rewardScene, + "rewardType": rewardType, + "rewardItemId": rewardItemId, + "rewardName": rewardName, + "rewardCover": rewardCover, + "rewardAmount": rewardAmount, + "expireDays": expireDays, + "grantStatus": grantStatus, + "popupStatus": popupStatus, + "grantTime": grantTime, + "createTime": createTime, + "errorMessage": errorMessage, + }; + } + static List listFromJson(dynamic value) { if (value is! Iterable) { return const []; @@ -371,6 +653,62 @@ String _stringValue(dynamic value) { return value.toString().trim(); } +String _firstAssetString(Iterable values) { + for (final value in values) { + final text = _assetString(value); + if (text.isNotEmpty) { + return text; + } + } + return ''; +} + +String _assetString(dynamic value) { + if (value == null) { + return ''; + } + if (value is Map) { + final map = _asMap(value); + return _firstAssetString([ + map['sourceUrl'], + map['url'], + map['resourceUrl'], + map['fileUrl'], + map['src'], + map['path'], + map['cover'], + map['imageUrl'], + map['svgUrl'], + map['svg'], + map['value'], + ]); + } + final map = _asMap(value); + if (map.isNotEmpty) { + return _firstAssetString([ + map['sourceUrl'], + map['url'], + map['resourceUrl'], + map['fileUrl'], + map['src'], + map['path'], + map['cover'], + map['imageUrl'], + map['svgUrl'], + map['svg'], + map['value'], + ]); + } + if (value is Iterable && value is! String) { + return _firstAssetString(value); + } + final text = _stringValue(value); + if (text == '{}' || text == '[]' || text.toLowerCase() == 'null') { + return ''; + } + return text; +} + int _intValue(dynamic value, {int fallback = 0}) { if (value is int) { return value; diff --git a/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart b/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart index 3ecc795..5292e17 100644 --- a/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart +++ b/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart @@ -46,6 +46,10 @@ class SCRoomRocketStatusRes { List? levels, SCRoomRocketRewardPreviewRes? rewardPreview, List? rocketKings, + String? timezone, + int? resetRemainingSeconds, + Object? resetTime, + Object? serverTime, }) { _roomId = roomId; _date = date; @@ -72,6 +76,10 @@ class SCRoomRocketStatusRes { _levels = levels; _rewardPreview = rewardPreview; _rocketKings = rocketKings; + _timezone = timezone; + _resetRemainingSeconds = resetRemainingSeconds; + _resetTime = resetTime; + _serverTime = serverTime; } SCRoomRocketStatusRes.fromJson(dynamic json) { @@ -104,6 +112,48 @@ class SCRoomRocketStatusRes { json['rewardPreview'] == null ? null : SCRoomRocketRewardPreviewRes.fromJson(json['rewardPreview']); + _timezone = _stringValue(json['timezone'] ?? json['timeZone']); + final resetRemainingMillis = _intValue( + json['resetRemainingMillis'] ?? + json['resetRemainMillis'] ?? + json['remainingMillis'] ?? + json['remainMillis'], + ); + _resetRemainingSeconds = + _intValue( + json['resetRemainingSeconds'] ?? + json['resetRemainSeconds'] ?? + json['remainingSeconds'] ?? + json['remainSeconds'] ?? + json['countdownSeconds'] ?? + json['resetCountdownSeconds'] ?? + json['secondsToReset'] ?? + json['resetSeconds'], + ) ?? + (resetRemainingMillis == null + ? null + : (resetRemainingMillis / 1000).ceil()); + _resetTime = _firstNonNull([ + json['resetTime'], + json['resetAt'], + json['resetDateTime'], + json['resetTimestamp'], + json['resetTimeMillis'], + json['nextResetTime'], + json['nextResetAt'], + json['nextResetDateTime'], + json['nextResetTimestamp'], + json['nextResetTimeMillis'], + json['dayEndTime'], + json['nextDayTime'], + ]); + _serverTime = _firstNonNull([ + json['serverTime'], + json['currentTime'], + json['currentTimestamp'], + json['now'], + json['time'], + ]); } String? _roomId; @@ -131,6 +181,10 @@ class SCRoomRocketStatusRes { List? _levels; SCRoomRocketRewardPreviewRes? _rewardPreview; List? _rocketKings; + String? _timezone; + int? _resetRemainingSeconds; + Object? _resetTime; + Object? _serverTime; String? get roomId => _roomId; @@ -182,6 +236,14 @@ class SCRoomRocketStatusRes { List? get rocketKings => _rocketKings; + String? get timezone => _timezone; + + int? get resetRemainingSeconds => _resetRemainingSeconds; + + Object? get resetTime => _resetTime; + + Object? get serverTime => _serverTime; + Map toJson() { final map = {}; map['roomId'] = _roomId; @@ -206,6 +268,10 @@ class SCRoomRocketStatusRes { map['needEnergy'] = _needEnergy; map['displayPercent'] = _displayPercent; map['shake'] = _shake; + map['timezone'] = _timezone; + map['resetRemainingSeconds'] = _resetRemainingSeconds; + map['resetTime'] = _resetTime; + map['serverTime'] = _serverTime; return map; } @@ -240,6 +306,27 @@ class SCRoomRocketStatusRes { return null; } + static String? _stringValue(dynamic value) { + final text = value?.toString().trim(); + if (text == null || text.isEmpty) { + return null; + } + return text; + } + + static Object? _firstNonNull(List values) { + for (final value in values) { + if (value == null) { + continue; + } + if (value is String && value.trim().isEmpty) { + continue; + } + return value; + } + return null; + } + static List? _levelList(dynamic value) { if (value is! Iterable) { return null; diff --git a/lib/shared/business_logic/models/res/sc_user_badge_res.dart b/lib/shared/business_logic/models/res/sc_user_badge_res.dart new file mode 100644 index 0000000..e7b6ec9 --- /dev/null +++ b/lib/shared/business_logic/models/res/sc_user_badge_res.dart @@ -0,0 +1,76 @@ +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; + +class SCUserBadgeRes { + SCUserBadgeRes({ + this.userId, + List? badges, + List? longBadges, + List? shortBadges, + }) : badges = badges ?? const [], + longBadges = longBadges ?? const [], + shortBadges = shortBadges ?? const []; + + factory SCUserBadgeRes.empty({String? userId}) { + return SCUserBadgeRes(userId: userId); + } + + factory SCUserBadgeRes.fromJson(dynamic json) { + final source = _bodyFrom(json); + if (source is! Map) { + return SCUserBadgeRes.empty(); + } + return SCUserBadgeRes( + userId: _stringValue(source['userId']), + badges: _badgeList(source['badges']), + longBadges: _badgeList(source['longBadges']), + shortBadges: _badgeList(source['shortBadges']), + ); + } + + final String? userId; + final List badges; + final List longBadges; + final List shortBadges; + + List get displayBadges { + if (longBadges.isNotEmpty || shortBadges.isNotEmpty) { + return [...longBadges, ...shortBadges]; + } + return badges; + } + + static dynamic _bodyFrom(dynamic json) { + if (json is Map) { + final body = json['body']; + if (body is Map) { + return body; + } + final data = json['data']; + if (data is Map) { + return data; + } + } + return json; + } + + static List _badgeList(dynamic value) { + if (value is! List) { + return const []; + } + return value + .whereType() + .map((item) => WearBadge.fromJson(item)) + .where((badge) => _badgeUrl(badge).isNotEmpty) + .toList(); + } + + static String _badgeUrl(WearBadge badge) { + return (badge.selectUrl ?? badge.notSelectUrl ?? badge.animationUrl ?? "") + .trim(); + } + + static String? _stringValue(dynamic value) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? null : text; + } +} diff --git a/lib/shared/business_logic/repositories/room_repository.dart b/lib/shared/business_logic/repositories/room_repository.dart index 0b7961d..f3c09e3 100644 --- a/lib/shared/business_logic/repositories/room_repository.dart +++ b/lib/shared/business_logic/repositories/room_repository.dart @@ -190,6 +190,17 @@ abstract class SocialChatRoomRepository { ///礼物背包 Future> giftBackpack(); + ///赠送背包礼物 + Future giveBackpackGift({ + required String id, + required String acceptUserId, + required String giftId, + required num quantity, + String? roomId, + num? seatIndex, + String? songId, + }); + ///查询房间火箭状态 Future rocketStatus(String roomId); @@ -203,7 +214,11 @@ abstract class SocialChatRoomRepository { }); ///查询中奖弹窗 - Future roomRocketRewardPopups(String roomId); + Future roomRocketRewardPopups( + String roomId, { + int cursor = 1, + int limit = 10, + }); ///确认中奖弹窗已读 Future ackRoomRocketRewardPopups(List rewardRecordIds); @@ -212,7 +227,7 @@ abstract class SocialChatRoomRepository { Future roomRocketRewardRecords( String roomId, { int cursor = 1, - int limit = 20, + int limit = 10, }); ///查询火箭规则说明 diff --git a/lib/shared/business_logic/repositories/user_repository.dart b/lib/shared/business_logic/repositories/user_repository.dart index 352d021..b983a3a 100644 --- a/lib/shared/business_logic/repositories/user_repository.dart +++ b/lib/shared/business_logic/repositories/user_repository.dart @@ -22,6 +22,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_rtc_token_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_sign_in_reward_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_sign_in_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_user_badge_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_counter_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_identity_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_level_exp_res.dart'; @@ -63,7 +64,13 @@ abstract class SocialChatUserRepository { Future createRoom(); ///加入房间 - Future entryRoom(String roomId, {String? pwd}); + Future entryRoom( + String roomId, { + String? pwd, + String redPackId = "", + bool needOpenRedenvelope = false, + bool silentErrorToast = false, + }); ///发送心跳 Future heartbeat(String status, bool upMick, {String? roomId}); @@ -248,6 +255,12 @@ abstract class SocialChatUserRepository { ///查询已拥有徽章 Future> badgeOwnList(String type); + ///查询当前用户正在展示的徽章 + Future currentUserBadges(); + + ///查询其他用户正在展示的徽章 + Future otherUserBadges(String userId); + ///切换佩戴徽章 Future badgeToggle(String badgeId); diff --git a/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart index 1fa75ae..7d5046d 100644 --- a/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart +++ b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart @@ -3,8 +3,11 @@ class RoomRocketLaunchBroadcastMessage { required this.roomId, required this.roomAccount, required this.roomName, + required this.roomCoverUrl, required this.roundNo, required this.level, + required this.rocketIconUrl, + required this.rocketAnimationUrl, required this.durationSeconds, required this.triggerUser, required this.triggerUserId, @@ -17,8 +20,11 @@ class RoomRocketLaunchBroadcastMessage { roomId: _stringValue(data['roomId']), roomAccount: _stringValue(data['roomAccount']), roomName: _stringValue(data['roomName']), + roomCoverUrl: _roomCoverUrl(data), roundNo: _intValue(data['roundNo']), level: _intValue(data['level'], fallback: 1), + rocketIconUrl: _rocketIconUrl(data), + rocketAnimationUrl: _rocketAnimationUrl(data), durationSeconds: _intValue(data['durationSeconds'], fallback: 7), triggerUser: RoomRocketLaunchTriggerUser.fromJson(data['triggerUser']), triggerUserId: _stringValue(data['triggerUserId']), @@ -29,8 +35,11 @@ class RoomRocketLaunchBroadcastMessage { final String roomId; final String roomAccount; final String roomName; + final String roomCoverUrl; final int roundNo; final int level; + final String rocketIconUrl; + final String rocketAnimationUrl; final int durationSeconds; final RoomRocketLaunchTriggerUser triggerUser; final String triggerUserId; @@ -103,6 +112,80 @@ String _stringValue(dynamic value) { return value.toString().trim(); } +String _roomCoverUrl(Map data) { + return _firstStringValue([ + data['roomCoverUrl'], + data['roomCover'], + data['roomAvatar'], + data['roomIcon'], + data['coverUrl'], + data['cover'], + _asMap(data['room'])['coverUrl'], + _asMap(data['room'])['cover'], + _asMap(data['room'])['avatar'], + ]); +} + +String _rocketIconUrl(Map data) { + return _firstStringValue([ + data['rocket_icon_url'], + data['rocketIconUrl'], + data['rocketIcon'], + data['rocketUrl'], + data['rocketImageUrl'], + data['rocketImage'], + data['iconUrl'], + _asMap(data['rocket'])['rocket_icon_url'], + _asMap(data['rocket'])['rocketIconUrl'], + _asMap(data['rocket'])['iconUrl'], + _asMap(data['rocket'])['icon'], + _asMap(data['levelInfo'])['rocket_icon_url'], + _asMap(data['levelInfo'])['rocketIconUrl'], + _asMap(data['rocketLevel'])['rocket_icon_url'], + _asMap(data['rocketLevel'])['rocketIconUrl'], + _asMap(data['config'])['rocket_icon_url'], + _asMap(data['config'])['rocketIconUrl'], + ]); +} + +String _rocketAnimationUrl(Map data) { + return _firstStringValue([ + data['rocket_animation_url'], + data['rocketAnimationUrl'], + data['rocketSvgaUrl'], + data['animationUrl'], + data['svgaUrl'], + data['rocketLaunchSvgaUrl'], + data['launchAnimationUrl'], + data['launchSvgaUrl'], + data['sourceUrl'], + _asMap(data['rocket'])['rocket_animation_url'], + _asMap(data['rocket'])['rocketAnimationUrl'], + _asMap(data['rocket'])['rocketSvgaUrl'], + _asMap(data['rocket'])['animationUrl'], + _asMap(data['rocket'])['svgaUrl'], + _asMap(data['levelInfo'])['rocket_animation_url'], + _asMap(data['levelInfo'])['rocketAnimationUrl'], + _asMap(data['levelInfo'])['rocketSvgaUrl'], + _asMap(data['rocketLevel'])['rocket_animation_url'], + _asMap(data['rocketLevel'])['rocketAnimationUrl'], + _asMap(data['rocketLevel'])['rocketSvgaUrl'], + _asMap(data['config'])['rocket_animation_url'], + _asMap(data['config'])['rocketAnimationUrl'], + _asMap(data['config'])['rocketSvgaUrl'], + ]); +} + +String _firstStringValue(Iterable values) { + for (final value in values) { + final text = _stringValue(value); + if (text.isNotEmpty) { + return text; + } + } + return ''; +} + int _intValue(dynamic value, {int fallback = 0}) { if (value is int) { return value; diff --git a/lib/shared/data_sources/sources/local/floating_screen_manager.dart b/lib/shared/data_sources/sources/local/floating_screen_manager.dart index fe0896f..8d374cf 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -27,6 +27,9 @@ class OverlayManager { static const int _maxDeferredRegionRedPacketCount = 12; static const Duration _compensationDedupWindow = Duration(seconds: 3); static const Duration _redPacketFloatingDedupWindow = Duration(minutes: 30); + static const List _stackedFloatingOrder = [5, 1, 4, 3]; + static const double _stackedFloatingBaseTop = 70; + static const double _stackedFloatingGap = 8; final SCPriorityQueue _messageQueue = SCPriorityQueue( (a, b) => b.priority.compareTo(a.priority), @@ -34,6 +37,8 @@ class OverlayManager { final List _deferredRegionRedPacketMessages = []; final Map _recentCompensationKeys = {}; final Map _recentFloatingKeys = {}; + final Map _stackedOverlayEntries = {}; + final Map _stackedMessages = {}; bool _isPlaying = false; OverlayEntry? _currentOverlayEntry; SCFloatingMessage? _currentMessage; @@ -55,7 +60,6 @@ class OverlayManager { if (_isRecentFloatingDuplicate(message)) { return; } - if (message.type == 4) {} _rememberFloatingMessage(message); _enqueueMessage( message, @@ -63,6 +67,7 @@ class OverlayManager { ); } else { _removeActiveMessage(scheduleNext: false); + _removeAllStackedMessages(); _messageQueue.clear(); _deferredRegionRedPacketMessages.clear(); _isPlaying = false; @@ -238,12 +243,14 @@ class OverlayManager { await SCSvgaAssetWidget.preloadAsset( FloatingLuckGiftScreenWidget.backgroundSvgaAssetPath, ); - } catch (error) {} + } catch (error) { + // Preload failure is non-fatal; the banner will retry during rendering. + } } void _safeScheduleNext() { if (_isSuppressed) return; - if (_isProcessing || _isPlaying || _messageQueue.isEmpty) return; + if (_isProcessing || _messageQueue.isEmpty) return; _isProcessing = true; try { @@ -254,7 +261,7 @@ class OverlayManager { } void _scheduleNext() { - if (_isPlaying || _messageQueue.isEmpty || _isDisposed || _isSuppressed) { + if (_messageQueue.isEmpty || _isDisposed || _isSuppressed) { return; } @@ -262,37 +269,64 @@ class OverlayManager { final context = navigatorKey.currentState?.context; if (context == null || !context.mounted) return; - // 安全地获取第一个消息 - if (_messageQueue.isEmpty) return; - final messageToProcess = _messageQueue.first; - if (messageToProcess == null) { - _messageQueue.removeFirst(); - _safeScheduleNext(); - return; - } - - if (!_shouldDisplayMessage(context, messageToProcess)) { - _messageQueue.removeFirst(); - if (_shouldDeferRegionRedPacketMessage(context, messageToProcess)) { - _deferRegionRedPacketMessage(context, messageToProcess); - _safeScheduleNext(); + while (_messageQueue.isNotEmpty) { + final messageToPlay = _removeNextPlayableMessage(context); + if (messageToPlay == null) { + return; + } + _playMessage(messageToPlay); + if (!_isStackedFloatingMessage(messageToPlay)) { return; } - _logRegionRedPacketDrop(context, messageToProcess); - _safeScheduleNext(); - return; } - - // 安全地移除并播放消息 - final messageToPlay = _messageQueue.removeFirst(); - _playMessage(messageToPlay); } catch (e) { _isPlaying = false; _safeScheduleNext(); } } + SCFloatingMessage? _removeNextPlayableMessage(BuildContext context) { + final skippedMessages = []; + SCFloatingMessage? selectedMessage; + + while (_messageQueue.isNotEmpty) { + final message = _messageQueue.removeFirst(); + if (!_shouldDisplayMessage(context, message)) { + if (_shouldDeferRegionRedPacketMessage(context, message)) { + _deferRegionRedPacketMessage(context, message); + continue; + } + _logRegionRedPacketDrop(context, message); + continue; + } + if (!_canPlayMessage(message)) { + skippedMessages.add(message); + continue; + } + selectedMessage = message; + break; + } + + for (final message in skippedMessages) { + _messageQueue.add(message); + } + return selectedMessage; + } + + bool _canPlayMessage(SCFloatingMessage message) { + final slotKey = _stackSlotKeyFor(message); + if (slotKey != null) { + return !_isPlaying && !_stackedOverlayEntries.containsKey(slotKey); + } + return !_isPlaying && _stackedOverlayEntries.isEmpty; + } + void _playMessage(SCFloatingMessage message) { + if (_isStackedFloatingMessage(message)) { + _playStackedMessage(message); + return; + } + _isPlaying = true; _currentMessage = message; final context = navigatorKey.currentState?.context; @@ -303,21 +337,6 @@ class OverlayManager { return; } - _currentOverlayEntry = OverlayEntry( - builder: - (_) => Align( - alignment: AlignmentDirectional.topStart, - child: Transform.translate( - offset: Offset(0, 70.w), - child: RepaintBoundary(child: _buildScreenWidget(message)), - ), - ), - ); - - Overlay.of(context).insert(_currentOverlayEntry!); - } - - Widget _buildScreenWidget(SCFloatingMessage message) { bool completed = false; void onComplete() { @@ -337,6 +356,66 @@ class OverlayManager { } } + _currentOverlayEntry = OverlayEntry( + builder: + (_) => Align( + alignment: AlignmentDirectional.topStart, + child: Transform.translate( + offset: Offset(0, 70.w), + child: RepaintBoundary( + child: _buildScreenWidget(message, onComplete), + ), + ), + ), + ); + + Overlay.of(context).insert(_currentOverlayEntry!); + } + + void _playStackedMessage(SCFloatingMessage message) { + final slotKey = _stackSlotKeyFor(message); + final context = navigatorKey.currentState?.context; + if (slotKey == null || context == null || !context.mounted) { + return; + } + if (_stackedOverlayEntries.containsKey(slotKey)) { + _messageQueue.add(message); + return; + } + + bool completed = false; + + void onComplete() { + if (completed) return; + completed = true; + + _removeStackedMessage(slotKey); + _markStackedEntriesNeedsBuild(); + _safeScheduleNext(); + } + + _stackedMessages[slotKey] = message; + final entry = OverlayEntry( + builder: + (_) => Align( + alignment: AlignmentDirectional.topStart, + child: Transform.translate( + offset: Offset(0, _stackedWrapperTopFor(message).w), + child: RepaintBoundary( + child: _buildScreenWidget(message, onComplete), + ), + ), + ), + ); + _stackedOverlayEntries[slotKey] = entry; + Overlay.of(context).insert(entry); + _markStackedEntriesNeedsBuild(); + } + + Widget _buildScreenWidget( + SCFloatingMessage message, + VoidCallback onComplete, + ) { switch (message.type) { case 0: return FloatingLuckGiftScreenWidget( @@ -379,6 +458,94 @@ class OverlayManager { } } + int? _stackSlotKeyFor(SCFloatingMessage message) { + switch (message.type) { + case 0: + case 1: + return 1; + case 3: + return 3; + case 4: + return 4; + case 5: + return 5; + default: + return null; + } + } + + bool _isStackedFloatingMessage(SCFloatingMessage message) { + return _stackSlotKeyFor(message) != null; + } + + double _stackedWrapperTopFor(SCFloatingMessage message) { + final slotKey = _stackSlotKeyFor(message); + if (slotKey == null) { + return _stackedFloatingBaseTop; + } + var visualTop = _stackedFloatingBaseTop; + for (final key in _stackedFloatingOrder) { + if (key == slotKey) { + break; + } + final previousMessage = _stackedMessages[key]; + if (previousMessage != null) { + visualTop += + _stackedVisualHeightFor(previousMessage) + _stackedFloatingGap; + } + } + final wrapperTop = visualTop - _stackedInternalTopFor(message); + return wrapperTop < 0 ? 0 : wrapperTop; + } + + double _stackedVisualHeightFor(SCFloatingMessage message) { + switch (message.type) { + case 0: + return 83; + case 1: + return 54; + case 3: + return 68; + case 4: + return 66; + case 5: + return 84; + default: + return 68; + } + } + + double _stackedInternalTopFor(SCFloatingMessage message) { + switch (message.type) { + case 1: + return 20; + case 4: + return 42; + default: + return 0; + } + } + + void _markStackedEntriesNeedsBuild() { + for (final entry in _stackedOverlayEntries.values) { + entry.markNeedsBuild(); + } + } + + void _removeStackedMessage(int slotKey) { + try { + _stackedOverlayEntries.remove(slotKey)?.remove(); + } catch (_) {} + _stackedMessages.remove(slotKey); + } + + void _removeAllStackedMessages() { + final slotKeys = List.from(_stackedOverlayEntries.keys); + for (final slotKey in slotKeys) { + _removeStackedMessage(slotKey); + } + } + void activate() { _isDisposed = false; _loadSavedDisplayPreference(); @@ -426,6 +593,7 @@ class OverlayManager { void beginSuppressFloatingScreens({String reason = 'unknown'}) { _suppressedCount += 1; _removeActiveMessage(scheduleNext: false); + _removeAllStackedMessages(); _messageQueue.clear(); _isPlaying = false; _isProcessing = false; @@ -446,6 +614,7 @@ class OverlayManager { } if (SCGlobalConfig.isFloatingBroadcastClosed) { _removeActiveMessage(scheduleNext: false); + _removeAllStackedMessages(); _messageQueue.clear(); _deferredRegionRedPacketMessages.clear(); return; @@ -457,8 +626,10 @@ class OverlayManager { context.mounted && activeMessage != null && !_shouldDisplayMessage(context, activeMessage)) { - _removeActiveMessage(); - return; + _removeActiveMessage(scheduleNext: false); + } + if (context != null && context.mounted) { + _removeHiddenStackedMessages(context); } _safeScheduleNext(); } @@ -470,6 +641,7 @@ class OverlayManager { _currentOverlayEntry?.remove(); _currentOverlayEntry = null; _currentMessage = null; + _removeAllStackedMessages(); _messageQueue.clear(); _deferredRegionRedPacketMessages.clear(); _recentCompensationKeys.clear(); @@ -486,6 +658,9 @@ class OverlayManager { _removeMessagesByType(1); _removeMessagesByType(0); _removeMessagesByType(5); + if (normalizedRoomId.isNotEmpty) { + _removeMessagesByType(3, roomId: normalizedRoomId); + } if (normalizedRoomId.isNotEmpty) { _removeMessagesByType(4, roomId: normalizedRoomId); } @@ -516,12 +691,20 @@ class OverlayManager { while (newQueue.isNotEmpty) { _messageQueue.add(newQueue.removeFirst()); } + + _removeStackedMessagesWhere((message) { + final matchesType = message.type == type; + final matchesRoom = + normalizedRoomId.isEmpty || + (message.roomId ?? "").trim() == normalizedRoomId; + return matchesType && matchesRoom; + }); } // 辅助方法:获取队列信息 int get queueLength => _messageQueue.length; - bool get isPlaying => _isPlaying; + bool get isPlaying => _isPlaying || _stackedOverlayEntries.isNotEmpty; bool get _isSuppressed => _suppressedCount > 0; @@ -580,19 +763,6 @@ class OverlayManager { } } - String _displayStateForLog(BuildContext context) { - final rtcProvider = Provider.of( - context, - listen: false, - ); - final currentRoomId = - rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; - return 'homeVisible=$_homeRootTabsVisible ' - 'roomVisible=${rtcProvider.shouldShowRoomVisualEffects} ' - 'currentRoomId=$currentRoomId ' - 'scope=${SCGlobalConfig.floatingBroadcastScope}'; - } - bool _shouldDisplayMessage(BuildContext context, SCFloatingMessage? message) { if (message == null) { return false; @@ -702,13 +872,13 @@ class OverlayManager { void _removeActiveRegionBroadcastMessage() { final activeMessage = _currentMessage; - if (activeMessage == null || !activeMessage.isRegionBroadcast) { - return; + if (activeMessage != null && activeMessage.isRegionBroadcast) { + _currentOverlayEntry?.remove(); + _currentOverlayEntry = null; + _currentMessage = null; + _isPlaying = false; } - _currentOverlayEntry?.remove(); - _currentOverlayEntry = null; - _currentMessage = null; - _isPlaying = false; + _removeStackedMessagesWhere((message) => message.isRegionBroadcast); _safeScheduleNext(); } @@ -718,18 +888,21 @@ class OverlayManager { }) { final activeMessage = _currentMessage; final normalizedRoomId = roomId?.trim() ?? ''; - if (activeMessage == null || - (activeMessage.type != 0 && - activeMessage.type != 1 && - activeMessage.type != 5 && - !(includeRedPacket && activeMessage.type == 4))) { - return; + if (activeMessage != null && + (activeMessage.type == 0 || + activeMessage.type == 1 || + activeMessage.type == 3 || + activeMessage.type == 5 || + (includeRedPacket && activeMessage.type == 4)) && + (normalizedRoomId.isEmpty || + (activeMessage.roomId ?? "").trim() == normalizedRoomId)) { + _removeActiveMessage(scheduleNext: false); } - if (normalizedRoomId.isNotEmpty && - (activeMessage.roomId ?? "").trim() != normalizedRoomId) { - return; - } - _removeActiveMessage(); + _removeStackedRoomMessages( + roomId: normalizedRoomId, + includeRedPacket: includeRedPacket, + ); + _safeScheduleNext(); } void _removeActiveMessage({bool scheduleNext = true}) { @@ -741,4 +914,47 @@ class OverlayManager { _safeScheduleNext(); } } + + void _removeHiddenStackedMessages(BuildContext context) { + _removeStackedMessagesWhere( + (message) => !_shouldDisplayMessage(context, message), + ); + } + + void _removeStackedRoomMessages({ + required String roomId, + required bool includeRedPacket, + }) { + _removeStackedMessagesWhere((message) { + final type = message.type; + final isRoomFloatingType = + type == 0 || + type == 1 || + type == 3 || + type == 5 || + (includeRedPacket && type == 4); + if (!isRoomFloatingType) { + return false; + } + return roomId.isEmpty || (message.roomId ?? "").trim() == roomId; + }); + } + + void _removeStackedMessagesWhere( + bool Function(SCFloatingMessage message) test, + ) { + final matchedSlotKeys = []; + _stackedMessages.forEach((slotKey, message) { + if (test(message)) { + matchedSlotKeys.add(slotKey); + } + }); + if (matchedSlotKeys.isEmpty) { + return; + } + for (final slotKey in matchedSlotKeys) { + _removeStackedMessage(slotKey); + } + _markStackedEntriesNeedsBuild(); + } } diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 81e2e03..fe5c4c1 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -122,6 +122,193 @@ class SCChatRoomRepository implements SocialChatRoomRepository { debugPrint(parts.join(' ')); } + void _rocketStatusAssetDebug(dynamic raw, SCRoomRocketStatusRes result) { + if (!kDebugMode) { + return; + } + final rawLevels = _debugMap(raw)['levels']; + final rawList = rawLevels is Iterable ? rawLevels.toList() : const []; + final parsed = result.levels ?? const []; + debugPrint( + '[RoomRocketApi] status asset summary levels=${parsed.length} ' + 'currentLevel=${result.currentLevel} displayPercent=${result.displayPercent}', + ); + for (var index = 0; index < parsed.length; index += 1) { + final item = parsed[index]; + final rawItem = + index < rawList.length + ? _debugMap(rawList[index]) + : const {}; + debugPrint( + '[RoomRocketApi] status level asset ' + 'index=$index level=${item.level} rawKeys=${rawItem.keys.toList()} ' + 'rawIcon=${_shortDebugAsset(_debugAssetValue(rawItem, _debugIconKeys))} ' + 'rawPreview=${_shortDebugAsset(_debugAssetValue(rawItem, _debugPreviewKeys))} ' + 'rawAnimation=${_shortDebugAsset(_debugAssetValue(rawItem, _debugAnimationKeys))} ' + 'parsedIcon=${_shortDebugAsset(item.rocketIconUrl)} ' + 'centerPreview=${_shortDebugAsset(item.rocketPreviewUrl)} ' + 'parsedAnimation=${_shortDebugAsset(item.rocketAnimationUrl)} ' + 'progress=${_shortDebugAsset(item.progressBarUrl)} ' + 'shakeThreshold=${item.shakeThresholdPercent}', + ); + } + } + + Map _debugMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, item) => MapEntry(key.toString(), item)); + } + return const {}; + } + + static const List _debugIconKeys = [ + 'rocket_icon_url', + 'rocketIconUrl', + 'rocketIcon', + 'iconUrl', + 'icon', + ]; + + static const List _debugPreviewKeys = [ + 'preview_rocket_url', + 'previewRocketUrl', + 'rocketPreviewUrl', + 'rocketPreviewSvgUrl', + 'rocketPreviewSvg', + 'rocketPreview', + 'rocketSvgUrl', + 'rocketSvg', + 'previewSvgUrl', + 'previewSvg', + 'previewUrl', + 'preview', + 'svgUrl', + 'svg', + 'rocketDisplayUrl', + 'rocketShowUrl', + 'rocketImageUrl', + 'previewImageUrl', + 'sourceUrl', + ]; + + static const List _debugAnimationKeys = [ + 'rocket_animation_url', + 'rocketAnimationUrl', + 'rocketAnimation', + 'rocketSvgaUrl', + 'animationUrl', + 'animation', + 'svgaUrl', + 'rocketLaunchSvgaUrl', + 'launchAnimationUrl', + 'launchAnimation', + 'launchSvgaUrl', + 'sourceUrl', + ]; + + String _debugAssetValue(Map data, List keys) { + for (final key in keys) { + final value = _debugNestedAssetValue(data[key]); + if (value.isNotEmpty) { + return value; + } + } + for (final nestedKey in const [ + 'rocket', + 'levelInfo', + 'config', + 'resource', + 'rocketResource', + 'previewResource', + 'animationResource', + ]) { + final nested = _debugMap(data[nestedKey]); + if (nested.isEmpty) { + continue; + } + final value = _debugAssetValue(nested, keys); + if (value.isNotEmpty) { + return value; + } + } + return ''; + } + + String _debugNestedAssetValue(dynamic value) { + if (value is Map) { + final nested = _debugMap(value); + for (final key in const [ + 'sourceUrl', + 'url', + 'resourceUrl', + 'fileUrl', + 'src', + 'path', + 'cover', + 'imageUrl', + 'svgUrl', + 'svg', + 'value', + ]) { + final text = _debugNestedAssetValue(nested[key]); + if (text.isNotEmpty) { + return text; + } + } + return ''; + } + final nested = _debugMap(value); + if (nested.isNotEmpty) { + for (final key in const [ + 'sourceUrl', + 'url', + 'resourceUrl', + 'fileUrl', + 'src', + 'path', + 'cover', + 'imageUrl', + 'svgUrl', + 'svg', + 'value', + ]) { + final text = _debugNestedAssetValue(nested[key]); + if (text.isNotEmpty) { + return text; + } + } + return ''; + } + if (value is Iterable && value is! String) { + for (final item in value) { + final text = _debugNestedAssetValue(item); + if (text.isNotEmpty) { + return text; + } + } + return ''; + } + final text = value?.toString().trim() ?? ''; + if (text == '{}' || text == '[]' || text.toLowerCase() == 'null') { + return ''; + } + return text; + } + + String _shortDebugAsset(String value) { + final text = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (text.isEmpty) { + return '-'; + } + if (text.length <= 140) { + return text; + } + return '${text.substring(0, 137)}...'; + } + dynamic _jsonInt64(String value) { final normalized = value.trim(); return int.tryParse(normalized) ?? normalized; @@ -552,23 +739,25 @@ class SCChatRoomRepository implements SocialChatRoomRepository { ///room/setting/locked @override Future roomLocked(String roomId, String password) async { - final result = await http.put( + await http.put( "e9531da4dd5c83bd5386366519ac6d0b727c997eb7a1adb8323f850b6c541f3b", data: {"roomId": roomId, "password": password}, - fromJson: (json) => json as bool, + allowNullBody: true, + fromJson: (json) => json, ); - return result; + return true; } ///room/setting/unlocked @override Future roomUnlocked(String roomId) async { - final result = await http.post( + await http.post( "d8e78b8fb4cf9f0d41716d9978235334d9382f6a419ce06c2dab0b362c318a5d", data: {"roomId": roomId}, - fromJson: (json) => json as bool, + allowNullBody: true, + fromJson: (json) => json, ); - return result; + return true; } ///room/setting @@ -768,11 +957,48 @@ class SCChatRoomRepository implements SocialChatRoomRepository { (json) => (json as List) .map((e) => SocialChatGiftBackpackRes.fromJson(e)) + .where((item) => item.giftConfig != null) .toList(), ); return result; } + ///gift/backpack/give-away + @override + Future giveBackpackGift({ + required String id, + required String acceptUserId, + required String giftId, + required num quantity, + String? roomId, + num? seatIndex, + String? songId, + }) async { + final params = { + "id": id, + "acceptUserId": acceptUserId, + "giftId": giftId, + "quantity": quantity, + }; + if (roomId != null && roomId.trim().isNotEmpty) { + params["roomId"] = roomId; + } + if (seatIndex != null) { + params["seatIndex"] = seatIndex; + } + if (songId != null && songId.trim().isNotEmpty) { + params["songId"] = songId; + } + final result = await http.post( + "/gift/backpack/give-away", + data: params, + allowNullBody: true, + extra: const {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => json, + ); + return result; + } + ///go/app/voice-room/rocket/status @override Future rocketStatus(String roomId) async { @@ -786,7 +1012,9 @@ class SCChatRoomRepository implements SocialChatRoomRepository { extra: {BaseNetworkClient.silentErrorToastKey: true}, fromJson: (json) { _rocketApiDebug('GET $path', query: params, raw: json); - return SCRoomRocketStatusRes.fromJson(json); + final status = SCRoomRocketStatusRes.fromJson(json); + _rocketStatusAssetDebug(json, status); + return status; }, ); _rocketApiDebug('parsed $path', query: params, result: result); @@ -827,9 +1055,15 @@ class SCChatRoomRepository implements SocialChatRoomRepository { ///go/app/voice-room/rocket/reward-popups @override Future roomRocketRewardPopups( - String roomId, - ) async { - final queryParams = {if (roomId.trim().isNotEmpty) "roomId": roomId}; + String roomId, { + int cursor = 1, + int limit = 10, + }) async { + final queryParams = { + if (roomId.trim().isNotEmpty) "roomId": roomId, + "cursor": cursor, + "limit": limit, + }; const path = "/go/app/voice-room/rocket/reward-popups"; _rocketApiDebug('request GET $path', query: queryParams); final result = await http.get( @@ -875,7 +1109,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { Future roomRocketRewardRecords( String roomId, { int cursor = 1, - int limit = 20, + int limit = 10, }) async { final queryParams = { if (roomId.trim().isNotEmpty) "roomId": roomId, 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 2f97b17..2d527ca 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 @@ -12,6 +12,7 @@ import 'package:yumi/shared/business_logic/models/res/room_res.dart' import 'package:yumi/shared/business_logic/models/res/sc_rtc_token_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_counter_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_user_badge_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_identity_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_level_exp_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_red_packet_grab_res.dart'; @@ -183,6 +184,7 @@ class SCAccountRepository implements SocialChatUserRepository { String? pwd, String redPackId = "", bool needOpenRedenvelope = false, + bool silentErrorToast = false, }) async { var param = {}; param["roomId"] = roomId; @@ -195,6 +197,10 @@ class SCAccountRepository implements SocialChatUserRepository { final result = await http.post( "363603111e51beac2d183014dd29b81c219577507bcc9c6abfaf327548fed0e0", data: param, + extra: + silentErrorToast + ? const {BaseNetworkClient.silentErrorToastKey: true} + : null, fromJson: (json) => JoinRoomRes.fromJson(json), ); return SCRoomProfileCache.applyToJoinRoomRes(result); @@ -1181,6 +1187,32 @@ class SCAccountRepository implements SocialChatUserRepository { return result; } + ///app/user/badge/current + @override + Future currentUserBadges() async { + const path = "/app/user/badge/current"; + final result = await http.get( + path, + extra: const {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCUserBadgeRes.fromJson(json), + ); + return result; + } + + ///app/user/badge/other + @override + Future otherUserBadges(String userId) async { + final params = {"userId": userId}; + const path = "/app/user/badge/other"; + final result = await http.get( + path, + queryParams: params, + extra: const {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCUserBadgeRes.fromJson(json), + ); + return result; + } + ///user/badge/toggle @override Future badgeToggle(String badgeId) async { diff --git a/lib/shared/tools/sc_path_utils.dart b/lib/shared/tools/sc_path_utils.dart index 1d5b8e3..da4dbbb 100644 --- a/lib/shared/tools/sc_path_utils.dart +++ b/lib/shared/tools/sc_path_utils.dart @@ -53,6 +53,7 @@ class SCPathUtils { '.mp4', '.vap', '.svga', + '.pag', ].contains(ext); return isDirectAsset && isSupportedAsset; @@ -76,6 +77,7 @@ class SCPathUtils { '.mov', '.avi', '.svga', + '.pag', '.png', '.jpg', '.txt', diff --git a/lib/shared/tools/sc_room_utils.dart b/lib/shared/tools/sc_room_utils.dart index 23b2222..d495f75 100644 --- a/lib/shared/tools/sc_room_utils.dart +++ b/lib/shared/tools/sc_room_utils.dart @@ -23,6 +23,24 @@ typedef SCRoomUtils = SCChatRoomHelper; class SCChatRoomHelper { static Map roomUsersMap = {}; + static bool isCurrentRoom(String roomId, BuildContext context) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return false; + } + try { + final currentRoomId = + Provider.of( + context, + listen: false, + ).currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? + ''; + return currentRoomId.isNotEmpty && currentRoomId == normalizedRoomId; + } catch (_) { + return false; + } + } + static void goRoom( String roomId, BuildContext context, { diff --git a/lib/ui_kit/widgets/badge/sc_user_badge_strip.dart b/lib/ui_kit/widgets/badge/sc_user_badge_strip.dart new file mode 100644 index 0000000..0ee187f --- /dev/null +++ b/lib/ui_kit/widgets/badge/sc_user_badge_strip.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_user_badge_res.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; + +class SCUserBadgeStrip extends StatefulWidget { + const SCUserBadgeStrip({ + super.key, + this.userId, + this.fallbackBadges = const [], + this.height, + this.badgeHeight, + this.longBadgeWidth, + this.spacing, + this.maxBadges, + this.wrap = false, + this.reserveSpace = false, + this.alignment = WrapAlignment.center, + }); + + final String? userId; + final List fallbackBadges; + final double? height; + final double? badgeHeight; + final double? longBadgeWidth; + final double? spacing; + final int? maxBadges; + final bool wrap; + final bool reserveSpace; + final WrapAlignment alignment; + + @override + State createState() => _SCUserBadgeStripState(); +} + +class _SCUserBadgeStripState extends State { + static final Map> _badgeFutures = {}; + + Future? _future; + + @override + void initState() { + super.initState(); + _future = _resolveFuture(); + } + + @override + void didUpdateWidget(covariant SCUserBadgeStrip oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId) { + _future = _resolveFuture(); + } + } + + Future? _resolveFuture() { + final userId = _normalizedUserId; + if (userId.isEmpty) { + return null; + } + final currentUserId = + AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? ""; + final isCurrentUser = currentUserId.isNotEmpty && userId == currentUserId; + final cacheKey = isCurrentUser ? "current:$userId" : "other:$userId"; + return _badgeFutures.putIfAbsent(cacheKey, () async { + try { + return isCurrentUser + ? await SCAccountRepository().currentUserBadges() + : await SCAccountRepository().otherUserBadges(userId); + } catch (_) { + _badgeFutures.remove(cacheKey); + return SCUserBadgeRes.empty(userId: userId); + } + }); + } + + String get _normalizedUserId => widget.userId?.trim() ?? ""; + + @override + Widget build(BuildContext context) { + final stripHeight = widget.height ?? 25.w; + final future = _future; + if (future == null) { + return _buildBadges(_fallbackBadges, stripHeight); + } + return FutureBuilder( + future: future, + builder: (context, snapshot) { + final remoteBadges = + snapshot.data?.displayBadges ?? const []; + final badges = remoteBadges.isNotEmpty ? remoteBadges : _fallbackBadges; + return _buildBadges(badges, stripHeight); + }, + ); + } + + List get _fallbackBadges { + return widget.fallbackBadges + .where((badge) => _badgeUrl(badge).isNotEmpty) + .toList(); + } + + Widget _buildBadges(List rawBadges, double stripHeight) { + final badges = + widget.maxBadges == null + ? rawBadges + : rawBadges.take(widget.maxBadges!).toList(); + if (badges.isEmpty) { + return widget.reserveSpace + ? SizedBox(height: stripHeight) + : const SizedBox.shrink(); + } + + final badgeHeight = widget.badgeHeight ?? stripHeight; + final spacing = widget.spacing ?? 5.w; + final children = + badges + .map((badge) => _buildBadge(badge, badgeHeight: badgeHeight)) + .toList(); + + if (widget.wrap) { + return SizedBox( + height: stripHeight, + child: Center( + child: Wrap( + alignment: widget.alignment, + spacing: spacing, + runSpacing: 5.w, + children: children, + ), + ), + ); + } + + return SizedBox( + height: stripHeight, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row(children: _withSpacing(children, spacing)), + ), + ); + } + + Widget _buildBadge(WearBadge badge, {required double badgeHeight}) { + final type = badge.type?.trim().toUpperCase() ?? ""; + final isLongBadge = type == "LONG"; + final width = + isLongBadge + ? (widget.longBadgeWidth ?? badgeHeight * 2.2) + : badgeHeight; + return netImage( + url: _badgeUrl(badge), + width: width, + height: badgeHeight, + fit: BoxFit.contain, + noDefaultImg: true, + loadingWidget: const SizedBox.shrink(), + errorWidget: const SizedBox.shrink(), + ); + } + + String _badgeUrl(WearBadge badge) { + return (badge.selectUrl ?? badge.notSelectUrl ?? badge.animationUrl ?? "") + .trim(); + } + + List _withSpacing(List children, double spacing) { + if (children.length < 2) { + return children; + } + final spaced = []; + for (var i = 0; i < children.length; i++) { + if (i > 0) { + spaced.add(SizedBox(width: spacing)); + } + spaced.add(children[i]); + } + return spaced; + } +} diff --git a/lib/ui_kit/widgets/room/empty_mai_select.dart b/lib/ui_kit/widgets/room/empty_mai_select.dart index 662640a..73ec623 100644 --- a/lib/ui_kit/widgets/room/empty_mai_select.dart +++ b/lib/ui_kit/widgets/room/empty_mai_select.dart @@ -247,7 +247,7 @@ class EmptyMaiSelect extends StatelessWidget { context, () { Navigator.of(context).pop(); - showBottomInCenterDialog( + showBottomInBottomDialog( context, RoomUserInfoCard(userId: clickUser?.id), ); diff --git a/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart index 78b6562..ca4550b 100644 --- a/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart +++ b/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_debouncer/flutter_debouncer.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/main.dart'; -import 'package:marquee/marquee.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; @@ -15,13 +13,13 @@ class FloatingRoomRocketScreenWidget extends StatefulWidget { final VoidCallback onAnimationCompleted; // 动画完成回调 const FloatingRoomRocketScreenWidget({ - Key? key, + super.key, required this.message, required this.onAnimationCompleted, - }) : super(key: key); + }); @override - _FloatingRoomRocketScreenWidgetState createState() => + State createState() => _FloatingRoomRocketScreenWidgetState(); } @@ -108,10 +106,7 @@ class _FloatingRoomRocketScreenWidgetState } Duration get _holdDuration { - final totalSeconds = widget.message.durationSeconds; - final totalMs = totalSeconds == null ? 6000 : (totalSeconds * 1000).round(); - final holdMs = totalMs - 3000; - return Duration(milliseconds: holdMs.clamp(0, 10000).toInt()); + return const Duration(seconds: 7); } @override @@ -139,19 +134,32 @@ class _FloatingRoomRocketScreenWidgetState } Widget _buildRocketAnimation() { + final roomName = _firstNonBlank([ + widget.message.userName, + widget.message.userId, + widget.message.roomId, + ]); + final rocketIconUrl = _firstNonBlank([ + widget.message.giftUrl, + widget.message.fallbackUrl, + ]); + // 使用Positioned.fill确保飘屏覆盖全屏宽度,并定位在特定高度(如顶部100像素) return GestureDetector( onTap: () { debouncer.debounce( duration: Duration(milliseconds: 350), onDebounce: () { - if (widget.message.roomId != null && - widget.message.roomId!.isNotEmpty) { - SCRoomUtils.goRoom( - widget.message.roomId!, - navigatorKey.currentState!.context, - fromFloting: true, - ); + final roomId = widget.message.roomId?.trim() ?? ''; + final currentContext = navigatorKey.currentState?.context; + if (roomId.isEmpty || + currentContext == null || + !currentContext.mounted || + SCRoomUtils.isCurrentRoom(roomId, currentContext)) { + return; + } + if (roomId.isNotEmpty) { + SCRoomUtils.goRoom(roomId, currentContext, fromFloting: true); } }, ); @@ -177,91 +185,78 @@ class _FloatingRoomRocketScreenWidgetState child: Directionality( textDirection: TextDirection.ltr, child: Container( - alignment: Alignment.topCenter, - height: 128.w, - width: 290.w, - margin: EdgeInsets.only(top: 10.w), - padding: EdgeInsetsDirectional.only(start: 3.w, end: 25.w), - decoration: BoxDecoration( + width: 307.w, + height: 68.w, + decoration: const BoxDecoration( image: DecorationImage( - image: AssetImage( - "sc_images/index/sc_icon_rocket_broad_bg_lv${widget.message.rocketLevel}.webp", - ), + image: AssetImage(_RocketFloatingAssets.bannerBg), fit: BoxFit.fill, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, // 宽度由内容决定 + child: Stack( + clipBehavior: Clip.none, children: [ - SizedBox(width: 5.w), - Container( + Positioned( + left: 21.w, + top: 8.w, + width: 38.w, + height: 38.w, child: head( - url: widget.message.userAvatarUrl ?? "", - width: 66.w, + url: widget.message.userAvatarUrl ?? '', + width: 38.w, ), ), - SizedBox(width: 4.w), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: 6.w), - Row( - children: [ - SizedBox( - width: 60.w, - height: 22.w, - child: Marquee( - text: "ID:${widget.message.userId} ", - style: TextStyle( - fontSize: 15.sp, - color: Colors.yellow, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w900, - decoration: TextDecoration.none, - ), - scrollAxis: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.start, - blankSpace: 20.0, - velocity: 40.0, - pauseAfterRound: Duration(seconds: 1), - accelerationDuration: Duration(seconds: 1), - accelerationCurve: Curves.easeOut, - decelerationDuration: Duration( - milliseconds: 500, - ), - decelerationCurve: Curves.easeOut, - ), - ), - text( - SCAppLocalizations.of(context)!.launchedARocket, - textColor: Colors.white, - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic, - fontSize: 13.sp, - ), - ], - ), - Transform.translate( - offset: Offset(0, -5), - child: text( - SCAppLocalizations.of(context)!.letGoToWatch, - textColor: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 17.sp, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ], + Positioned( + left: 65.w, + top: 9.w, + width: 126.w, + child: Text( + roomName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), ), ), + Positioned( + left: 65.w, + top: 31.w, + width: 126.w, + child: Text( + SCAppLocalizations.of(context)?.launchRocket ?? + 'Launch Rocket', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.61), + fontSize: 12.sp, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ), + Positioned( + left: 201.w, + top: 8.w, + width: 40.w, + height: 40.w, + child: _RocketIcon(url: rocketIconUrl), + ), + Positioned( + left: 249.w, + top: 14.w, + width: 42.w, + height: 28.w, + child: const _RocketGoButton(), + ), ], ), ), @@ -285,4 +280,119 @@ class _FloatingRoomRocketScreenWidgetState _swipeController.reset(); _swipeController.forward(); } + + String _firstNonBlank(Iterable values) { + for (final value in values) { + final text = value?.trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return ''; + } +} + +class _RocketIcon extends StatelessWidget { + const _RocketIcon({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4.w), + color: const Color(0xFF08261F), + border: Border.all( + color: const Color(0xFF54F8D2).withValues(alpha: 0.5), + width: 0.7.w, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF14FFC7).withValues(alpha: 0.16), + blurRadius: 7.w, + ), + ], + ), + child: Padding( + padding: EdgeInsets.all(5.w), + child: + url.trim().isEmpty + ? Image.asset( + _RocketFloatingAssets.fallbackRocket, + fit: BoxFit.contain, + ) + : netImage( + url: url, + width: 30.w, + height: 30.w, + fit: BoxFit.contain, + noDefaultImg: true, + errorWidget: Image.asset( + _RocketFloatingAssets.fallbackRocket, + fit: BoxFit.contain, + ), + ), + ), + ); + } +} + +class _RocketGoButton extends StatelessWidget { + const _RocketGoButton(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999.w), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF0C745B), Color(0xFF013C31)], + ), + border: Border.all( + color: const Color(0xFF8DFFE3).withValues(alpha: 0.72), + width: 1.w, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF21FFC3).withValues(alpha: 0.28), + blurRadius: 6.w, + spreadRadius: 0.5.w, + ), + ], + ), + child: Center( + child: ShaderMask( + shaderCallback: + (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white, Color(0xFFA4FFE3)], + ).createShader(bounds), + blendMode: BlendMode.srcIn, + child: Text( + 'GO', + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + height: 1, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ), + ), + ); + } +} + +class _RocketFloatingAssets { + const _RocketFloatingAssets._(); + + static const String bannerBg = + 'sc_images/room/rocket/rocket_floating_banner_bg.png'; + static const String fallbackRocket = 'sc_images/room/rocket/rocket_body.png'; } diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart index 107f4b4..456c929 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart @@ -24,7 +24,17 @@ class RoomRocketApiMapper { .map( (item) => RoomRocketLevelItem( label: 'Lv.${item.level}', + level: item.level, imageUrl: item.rocketIconUrl.isEmpty ? null : item.rocketIconUrl, + previewImageUrl: + item.rocketPreviewUrl.isEmpty ? null : item.rocketPreviewUrl, + animationUrl: + item.rocketAnimationUrl.isEmpty + ? null + : item.rocketAnimationUrl, + progressImageUrl: + item.progressBarUrl.isEmpty ? null : item.progressBarUrl, + shakeThresholdPercent: item.shakeThresholdPercent, enabled: item.enabled, ), ) @@ -41,12 +51,28 @@ class RoomRocketApiMapper { return rewards.map(rewardItemFromPreview).toList(growable: false); } + static RoomRocketRewardGroups? rewardGroupsFromStatus( + SCRoomRocketStatusRes? status, + ) { + final preview = status?.rewardPreview; + if (preview == null) { + return null; + } + return RoomRocketRewardGroups( + top1: preview.top1.map(rewardItemFromPreview).toList(growable: false), + setOff: preview.ignite.map(rewardItemFromPreview).toList(growable: false), + inRoom: preview.inRoom.map(rewardItemFromPreview).toList(growable: false), + ); + } + static RoomRocketRewardItem rewardItemFromPreview( SCRoomRocketRewardRes reward, ) { return RoomRocketRewardItem( imageUrl: reward.rewardCover.isEmpty ? null : reward.rewardCover, amount: reward.displayAmount, + rewardType: reward.rewardType, + expireDays: reward.expireDays, ); } @@ -85,7 +111,8 @@ class RoomRocketApiMapper { .map((entry) { final first = entry.value.first; return RoomRocketRewardWinner( - nickname: first.userId.isNotEmpty ? 'ID:${first.userId}' : 'You', + nickname: first.displayName, + avatarUrl: first.userAvatar.isEmpty ? null : first.userAvatar, rewards: rewardItemsFromRecords(entry.value), ); }) @@ -133,6 +160,8 @@ class RoomRocketApiMapper { return RoomRocketRewardItem( imageUrl: record.rewardCover.isEmpty ? null : record.rewardCover, amount: record.displayAmount, + rewardType: record.rewardType, + expireDays: record.expireDays, ); } diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart index f204958..aff1d6d 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart @@ -1,8 +1,14 @@ +import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart'; +import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart'; class RoomRocketDialog extends StatelessWidget { const RoomRocketDialog({ @@ -14,6 +20,9 @@ class RoomRocketDialog extends StatelessWidget { this.selectedLevel = 0, this.onLevelSelected, this.rewards, + this.rewardGroups, + this.selectedRewardTab = RoomRocketRewardTab.top1, + this.onRewardTabSelected, this.crew, this.onClose, this.onOpenRank, @@ -29,6 +38,9 @@ class RoomRocketDialog extends StatelessWidget { final int selectedLevel; final ValueChanged? onLevelSelected; final List? rewards; + final RoomRocketRewardGroups? rewardGroups; + final RoomRocketRewardTab selectedRewardTab; + final ValueChanged? onRewardTabSelected; final List? crew; final VoidCallback? onClose; final VoidCallback? onOpenRank; @@ -39,9 +51,15 @@ class RoomRocketDialog extends StatelessWidget { @override Widget build(BuildContext context) { final resolvedLevels = _normalizeLevels(levels); - final resolvedRewards = _normalizeRewards(rewards); + final resolvedRewards = _normalizeRewards( + rewardGroups?.rewardsFor(selectedRewardTab) ?? rewards, + useFallback: rewardGroups == null, + ); final resolvedCrew = _normalizeCrew(crew, status); - final percent = _normalizePercent(status?.energyPercent); + final resolvedSelectedLevel = + selectedLevel.clamp(0, math.max(0, resolvedLevels.length - 1)).toInt(); + final selectedLevelItem = resolvedLevels[resolvedSelectedLevel]; + final percent = _percentForSelectedLevel(status, selectedLevelItem.level); final resolvedProgressStage = progressStage ?? RoomRocketProgressStage.fromPercent(percent); final resolvedBottomStage = @@ -81,11 +99,15 @@ class RoomRocketDialog extends StatelessWidget { percent: percent, progressStage: resolvedProgressStage, bottomStage: resolvedBottomStage, + status: status, round: round, levels: resolvedLevels, - selectedLevel: selectedLevel, + selectedLevel: resolvedSelectedLevel, + selectedLevelItem: selectedLevelItem, onLevelSelected: onLevelSelected, rewards: resolvedRewards, + selectedRewardTab: selectedRewardTab, + onRewardTabSelected: onRewardTabSelected, crew: resolvedCrew, onClose: onClose, onOpenRank: onOpenRank, @@ -117,20 +139,25 @@ class RoomRocketDialog extends StatelessWidget { } return List.generate( 6, - (index) => RoomRocketLevelItem(label: 'Lv.${index + 1}'), + (index) => + RoomRocketLevelItem(label: 'Lv.${index + 1}', level: index + 1), growable: false, ); } static List _normalizeRewards( - List? rewards, - ) { + List? rewards, { + required bool useFallback, + }) { final source = rewards?.where((item) => item.amount.trim().isNotEmpty).toList() ?? const []; if (source.isNotEmpty) { return source; } + if (!useFallback) { + return const []; + } return const [ RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'), RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'), @@ -165,27 +192,101 @@ class RoomRocketDialog extends StatelessWidget { final percent = raw > 0 && raw <= 1 ? raw * 100 : raw; return percent.clamp(0, 100).toDouble(); } + + static double _percentForSelectedLevel( + SCRoomRocketStatusRes? status, + int selectedLevel, + ) { + final currentLevel = + (status?.currentLevel ?? status?.level?.toInt() ?? selectedLevel) + .clamp(1, 99) + .toInt(); + if (selectedLevel < currentLevel) { + return 100; + } + if (selectedLevel > currentLevel) { + return 0; + } + return _normalizePercent(status?.displayPercent ?? status?.energyPercent); + } } class RoomRocketRewardItem { - const RoomRocketRewardItem({this.asset, this.imageUrl, required this.amount}); + const RoomRocketRewardItem({ + this.asset, + this.imageUrl, + required this.amount, + this.rewardType = 'GOLD', + this.expireDays = 0, + }); final String? asset; final String? imageUrl; final String amount; + final String rewardType; + final int expireDays; + + bool get isGoldReward { + final normalizedType = rewardType.trim().toUpperCase(); + return normalizedType == 'GOLD' || + normalizedType == 'COIN' || + normalizedType == 'COINS'; + } + + String get displayText { + if (isGoldReward) { + return amount; + } + return expireDays > 0 ? '${expireDays}D' : amount; + } +} + +enum RoomRocketRewardTab { top1, setOff, inRoom } + +class RoomRocketRewardGroups { + const RoomRocketRewardGroups({ + this.top1 = const [], + this.setOff = const [], + this.inRoom = const [], + }); + + final List top1; + final List setOff; + final List inRoom; + + List rewardsFor(RoomRocketRewardTab tab) { + switch (tab) { + case RoomRocketRewardTab.top1: + return top1; + case RoomRocketRewardTab.setOff: + return setOff; + case RoomRocketRewardTab.inRoom: + return inRoom; + } + } } class RoomRocketLevelItem { const RoomRocketLevelItem({ required this.label, + this.level = 1, this.asset, this.imageUrl, + this.previewImageUrl, + this.animationUrl, + this.progressImageUrl, + this.shakeThresholdPercent = 95, this.enabled = true, }); final String label; + final int level; final String? asset; final String? imageUrl; + final String? previewImageUrl; + final String? animationUrl; + final String? progressImageUrl; + final num shakeThresholdPercent; final bool enabled; } @@ -220,11 +321,15 @@ class _RocketCanvas extends StatelessWidget { required this.percent, required this.progressStage, required this.bottomStage, + required this.status, required this.round, required this.levels, required this.selectedLevel, + required this.selectedLevelItem, this.onLevelSelected, required this.rewards, + required this.selectedRewardTab, + this.onRewardTabSelected, required this.crew, this.onClose, this.onOpenRank, @@ -237,11 +342,15 @@ class _RocketCanvas extends StatelessWidget { final double percent; final RoomRocketProgressStage progressStage; final RoomRocketBottomStage bottomStage; + final SCRoomRocketStatusRes? status; final int round; final List levels; final int selectedLevel; + final RoomRocketLevelItem selectedLevelItem; final ValueChanged? onLevelSelected; final List rewards; + final RoomRocketRewardTab selectedRewardTab; + final ValueChanged? onRewardTabSelected; final List crew; final VoidCallback? onClose; final VoidCallback? onOpenRank; @@ -257,7 +366,7 @@ class _RocketCanvas extends StatelessWidget { clipBehavior: Clip.hardEdge, children: [ _image(_RocketAssets.rocketStage, 15, 361, 344, 97), - _image(_RocketAssets.rocketBody, 88, 111, 200, 432), + _buildRocketBody(), _image(_RocketAssets.sidePanel, 309, 202, 66, 207), _image(_RocketAssets.sidePanel, 0, 202, 66, 207), _buildLevelRail(), @@ -271,6 +380,24 @@ class _RocketCanvas extends StatelessWidget { ); } + Widget _buildRocketBody() { + return Positioned( + left: s(88), + top: s(111), + width: s(200), + height: s(432), + child: _RocketImage( + asset: selectedLevelItem.asset ?? _RocketAssets.rocketBody, + imageUrl: selectedLevelItem.previewImageUrl, + fit: BoxFit.contain, + preferSvg: true, + debugName: + 'centerPreview level=${selectedLevelItem.level} ' + 'preview=${selectedLevelItem.previewImageUrl ?? '-'}', + ), + ); + } + Widget _image( String asset, double left, @@ -347,9 +474,20 @@ class _RocketCanvas extends StatelessWidget { RoomRocketProgressStage.charging => _RocketAssets.progressPartial, RoomRocketProgressStage.full => _RocketAssets.progressFull, }; + final progressImageUrl = selectedLevelItem.progressImageUrl?.trim() ?? ''; return Stack( children: [ - _image(asset, 320, 227, 44, 134), + Positioned( + left: s(320), + top: s(227), + width: s(44), + height: s(134), + child: _RocketImage( + asset: asset, + imageUrl: progressImageUrl.isEmpty ? null : progressImageUrl, + fit: BoxFit.fill, + ), + ), Positioned( left: s(316), top: s(373), @@ -438,30 +576,12 @@ class _RocketCanvas extends StatelessWidget { color: Colors.white, ), ), - for (final item in const [ - _TimerToken('23', 193), - _TimerToken('23', 223), - _TimerToken('23', 253), - ]) - Positioned( - left: s(item.left), - top: s(468), - child: _RocketText( - item.value, - fontSize: s(12), - color: Color(0xFF2DFFC1), - ), - ), - for (final left in const [209.0, 239.0]) - Positioned( - left: s(left), - top: s(468), - child: _RocketText( - ':', - fontSize: s(12), - color: const Color(0xFF2DFFC1), - ), - ), + Positioned( + left: s(187), + top: s(468), + width: s(88), + child: _ResetCountdownText(status: status, scale: scale), + ), ], ); } @@ -500,10 +620,25 @@ class _RocketCanvas extends StatelessWidget { left: 31, top: 552, label: 'Top1', - active: true, + active: selectedRewardTab == RoomRocketRewardTab.top1, + onTap: () => onRewardTabSelected?.call(RoomRocketRewardTab.top1), + ), + _RewardTab( + scale: scale, + left: 147, + top: 552, + label: 'Set off', + active: selectedRewardTab == RoomRocketRewardTab.setOff, + onTap: () => onRewardTabSelected?.call(RoomRocketRewardTab.setOff), + ), + _RewardTab( + scale: scale, + left: 252, + top: 552, + label: 'In the room', + active: selectedRewardTab == RoomRocketRewardTab.inRoom, + onTap: () => onRewardTabSelected?.call(RoomRocketRewardTab.inRoom), ), - _RewardTab(scale: scale, left: 147, top: 552, label: 'Set off'), - _RewardTab(scale: scale, left: 252, top: 552, label: 'In the room'), Positioned( left: s(28), top: s(578), @@ -514,7 +649,9 @@ class _RocketCanvas extends StatelessWidget { behavior: const _NoGlowScrollBehavior(), child: SingleChildScrollView( scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), child: Row( children: [ for (var index = 0; index < rewards.length; index++) ...[ @@ -524,7 +661,11 @@ class _RocketCanvas extends StatelessWidget { child: _RewardCard( scale: scale, item: rewards[index], - onTap: () => onRewardSelected?.call(rewards[index]), + onTap: + onRewardSelected == null + ? null + : () => + onRewardSelected?.call(rewards[index]), ), ), if (index != rewards.length - 1) SizedBox(width: s(12)), @@ -564,7 +705,7 @@ class _RocketCanvas extends StatelessWidget { if (!activeBottom) Positioned( left: s(108), - top: s(750), + top: s(760), width: s(169), height: s(36), child: FittedBox( @@ -651,28 +792,24 @@ class _RewardCard extends StatelessWidget { top: 24 * scale, width: 50 * scale, height: 50 * scale, - child: _RocketImage( - asset: item.asset, - imageUrl: item.imageUrl, - fit: BoxFit.cover, - ), + child: + item.isGoldReward + ? Image.asset(_RocketAssets.coin, fit: BoxFit.contain) + : _RocketImage( + asset: item.asset, + imageUrl: item.imageUrl, + fit: BoxFit.contain, + ), ), Positioned( - left: 21 * scale, - top: 106 * scale, - width: 14 * scale, - height: 14 * scale, - child: Image.asset(_RocketAssets.coin, fit: BoxFit.contain), - ), - Positioned( - left: 37 * scale, + left: 16 * scale, top: 105 * scale, - width: 46 * scale, + width: 65 * scale, child: _RocketText( - item.amount, + item.displayText, fontSize: 14 * scale, color: Colors.white, - textAlign: TextAlign.left, + textAlign: TextAlign.center, ), ), ], @@ -792,18 +929,139 @@ class _CrewAvatar extends StatelessWidget { } class _RocketImage extends StatelessWidget { - const _RocketImage({this.asset, this.imageUrl, this.fit = BoxFit.cover}); + const _RocketImage({ + this.asset, + this.imageUrl, + this.fit = BoxFit.cover, + this.preferSvg = false, + this.debugName, + }); final String? asset; final String? imageUrl; final BoxFit fit; + final bool preferSvg; + final String? debugName; @override Widget build(BuildContext context) { if (imageUrl != null && imageUrl!.trim().isNotEmpty) { - return CustomCachedImage(imageUrl: imageUrl!, fit: fit); + final source = imageUrl!.trim(); + _debugImage('load ${_sourceKind(source)} ${_shortSource(source)}'); + if (SCNetworkSvgaWidget.isSvga(source)) { + return SCNetworkSvgaWidget( + resource: source, + loop: true, + fit: fit, + fallback: _imageFallback(), + ); + } + if (RoomRocketPagEffectOverlay.isPag(source)) { + return RoomRocketPagPreview( + resource: source, + loop: true, + defaultBuilder: (_) => const SCRotatingDotsLoading(), + ); + } + if (_isInlineSvg(source)) { + return SvgPicture.string( + source, + fit: fit, + errorBuilder: (_, error, __) { + _debugImage('inline svg failed: $error'); + return _imageFallback(); + }, + ); + } + if (_isSvg(source) || preferSvg) { + return SvgPicture.network( + source, + fit: fit, + placeholderBuilder: (_) => const SCRotatingDotsLoading(), + errorBuilder: (_, error, __) { + _debugImage('network svg failed: $error'); + return preferSvg && !_isSvg(source) + ? CustomCachedImage(imageUrl: source, fit: fit) + : _imageFallback(); + }, + ); + } + return CustomCachedImage(imageUrl: source, fit: fit); } if (asset != null && asset!.trim().isNotEmpty) { + final source = asset!.trim(); + _debugImage('load asset ${_shortSource(source)}'); + if (SCNetworkSvgaWidget.isSvga(source)) { + return SCNetworkSvgaWidget( + resource: source, + loop: true, + fit: fit, + fallback: _imageFallback(), + ); + } + if (RoomRocketPagEffectOverlay.isPag(source)) { + return RoomRocketPagPreview( + resource: source, + loop: true, + defaultBuilder: (_) => _imageFallback(), + ); + } + if (_isSvg(source)) { + return SvgPicture.asset( + source, + fit: fit, + errorBuilder: (_, error, __) { + _debugImage('asset svg failed: $error'); + return const SizedBox.shrink(); + }, + ); + } + return Image.asset(source, fit: fit, gaplessPlayback: true); + } + return const SizedBox.shrink(); + } + + static bool _isSvg(String value) { + final lower = value.trim().toLowerCase(); + return lower.endsWith('.svg') || lower.contains('.svg?'); + } + + static bool _isInlineSvg(String value) { + final trimmed = value.trimLeft(); + return trimmed.startsWith(' createState() => _ResetCountdownTextState(); +} + +class _ResetCountdownTextState extends State<_ResetCountdownText> { + Timer? _timer; + late DateTime _anchor; + late String _sourceKey; + int? _initialRemainingSeconds; + DateTime? _targetTime; + DateTime? _serverTimeAtAnchor; + + @override + void initState() { + super.initState(); + _resetSource(); + _timer = Timer.periodic( + const Duration(seconds: 1), + (_) => mounted ? setState(() {}) : null, + ); + } + + @override + void didUpdateWidget(covariant _ResetCountdownText oldWidget) { + super.didUpdateWidget(oldWidget); + final nextKey = _countdownSourceKey(widget.status); + if (nextKey != _sourceKey) { + _resetSource(); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _resetSource() { + _anchor = DateTime.now(); + _sourceKey = _countdownSourceKey(widget.status); + _initialRemainingSeconds = widget.status?.resetRemainingSeconds; + _targetTime = _targetTimeFromStatus(widget.status); + _serverTimeAtAnchor = _dateTimeFromValue( + widget.status?.serverTime, + _timezoneOffset(widget.status?.timezone), + ); + } + + @override + Widget build(BuildContext context) { + final parts = _formatDuration(_remainingSeconds()); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _countdownPart(parts[0]), + _separator(), + _countdownPart(parts[1]), + _separator(), + _countdownPart(parts[2]), + ], + ); + } + + Widget _countdownPart(String text) { + return SizedBox( + width: 23 * widget.scale, + child: _RocketText( + text, + fontSize: 12 * widget.scale, + color: const Color(0xFF2DFFC1), + ), + ); + } + + Widget _separator() { + return SizedBox( + width: 7 * widget.scale, + child: _RocketText( + ':', + fontSize: 12 * widget.scale, + color: const Color(0xFF2DFFC1), + ), + ); + } + + int _remainingSeconds() { + final elapsed = DateTime.now().difference(_anchor).inSeconds; + final directRemaining = _initialRemainingSeconds; + if (directRemaining != null) { + return math.max(0, directRemaining - elapsed); + } + + final target = _targetTime ?? _fallbackResetTarget(widget.status); + final serverTime = _serverTimeAtAnchor; + final now = + serverTime == null + ? DateTime.now() + : serverTime.add(Duration(seconds: elapsed)); + return math.max(0, target.difference(now).inSeconds); + } + + List _formatDuration(int totalSeconds) { + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + String two(int value) => value.toString().padLeft(2, '0'); + return [two(hours), two(minutes), two(seconds)]; + } + + static String _countdownSourceKey(SCRoomRocketStatusRes? status) { + return [ + status?.date, + status?.timezone, + status?.resetRemainingSeconds, + status?.resetTime, + status?.serverTime, + ].join('|'); + } + + static DateTime? _targetTimeFromStatus(SCRoomRocketStatusRes? status) { + return _dateTimeFromValue( + status?.resetTime, + _timezoneOffset(status?.timezone), + ); + } + + static DateTime _fallbackResetTarget(SCRoomRocketStatusRes? status) { + final offset = _timezoneOffset(status?.timezone); + final dayKey = status?.date?.trim() ?? ''; + if (RegExp(r'^\d{8}$').hasMatch(dayKey)) { + final year = int.parse(dayKey.substring(0, 4)); + final month = int.parse(dayKey.substring(4, 6)); + final day = int.parse(dayKey.substring(6, 8)); + return DateTime.utc(year, month, day + 1).subtract(offset).toLocal(); + } + + final businessNow = DateTime.now().toUtc().add(offset); + return DateTime.utc( + businessNow.year, + businessNow.month, + businessNow.day + 1, + ).subtract(offset).toLocal(); + } + + static DateTime? _dateTimeFromValue(Object? value, Duration offset) { + if (value == null) { + return null; + } + if (value is num) { + return _dateTimeFromTimestamp(value); + } + final text = value.toString().trim(); + if (text.isEmpty) { + return null; + } + final numeric = num.tryParse(text); + if (numeric != null) { + return _dateTimeFromTimestamp(numeric); + } + return _dateTimeFromText(text, offset); + } + + static DateTime _dateTimeFromTimestamp(num value) { + final milliseconds = + value.abs() < 1000000000000 ? (value * 1000).round() : value.round(); + return DateTime.fromMillisecondsSinceEpoch(milliseconds).toLocal(); + } + + static DateTime? _dateTimeFromText(String text, Duration offset) { + final normalized = text.replaceFirst(' ', 'T'); + final hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$').hasMatch(normalized); + if (hasZone) { + return DateTime.tryParse(normalized)?.toLocal(); + } + final match = RegExp( + r'^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?', + ).firstMatch(text); + if (match == null) { + return DateTime.tryParse(normalized)?.toLocal(); + } + final year = int.parse(match.group(1)!); + final month = int.parse(match.group(2)!); + final day = int.parse(match.group(3)!); + final hour = int.parse(match.group(4)!); + final minute = int.parse(match.group(5)!); + final second = int.tryParse(match.group(6) ?? '0') ?? 0; + return DateTime.utc( + year, + month, + day, + hour, + minute, + second, + ).subtract(offset).toLocal(); + } + + static Duration _timezoneOffset(String? timezone) { + final value = timezone?.trim(); + if (value == null || value.isEmpty) { + return const Duration(hours: 3); + } + final normalized = value.toUpperCase(); + final match = RegExp( + r'^(?:UTC|GMT)?([+-])(\d{1,2})(?::?(\d{2}))?$', + ).firstMatch(normalized); + if (match != null) { + final sign = match.group(1) == '-' ? -1 : 1; + final hours = int.parse(match.group(2)!); + final minutes = int.tryParse(match.group(3) ?? '0') ?? 0; + return Duration(hours: sign * hours, minutes: sign * minutes); + } + switch (value) { + case 'Asia/Riyadh': + case 'Asia/Kuwait': + case 'Asia/Qatar': + case 'Asia/Bahrain': + case 'Asia/Baghdad': + case 'Europe/Moscow': + return const Duration(hours: 3); + case 'Asia/Dubai': + case 'Asia/Muscat': + return const Duration(hours: 4); + case 'Asia/Shanghai': + case 'Asia/Hong_Kong': + case 'Asia/Singapore': + return const Duration(hours: 8); + default: + return const Duration(hours: 3); + } + } +} + class _GradientText extends StatelessWidget { const _GradientText( this.text, { @@ -1163,13 +1665,6 @@ class _GradientText extends StatelessWidget { } } -class _TimerToken { - const _TimerToken(this.value, this.left); - - final String value; - final double left; -} - class _RocketAssets { const _RocketAssets._(); diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart index 5df2daa..e5f089c 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart @@ -1,20 +1,25 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:provider/provider.dart'; import 'package:yumi/modules/room/voice_room_route.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/components/custom_cached_image.dart'; +import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart'; -import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart'; class RoomRocketFloatingEntry extends StatelessWidget { const RoomRocketFloatingEntry({super.key}); @@ -29,55 +34,53 @@ class RoomRocketFloatingEntry extends StatelessWidget { return Selector( selector: (_, provider) => provider.roomRocketStatus, builder: (context, status, child) { + final level = _currentEntryLevel(status); + final percent = _entryPercent(status); + final shouldShake = + (status?.shake ?? false) || + (level != null && percent > level.shakeThresholdPercent); return SCDebounceWidget( onTap: () => _showRocketDialog(context, status), - child: SizedBox( - width: 44.w, - height: 44.w, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF093D33), Color(0xFF061A16)], - ), - border: Border.all( - color: const Color(0xFF38F4BF).withValues(alpha: 0.78), - width: 1.w, - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF21FFC3).withValues(alpha: 0.24), - blurRadius: 10.w, - spreadRadius: 1.w, - ), - ], - ), - child: ClipOval( - child: Stack( - alignment: Alignment.center, - children: [ - Positioned( - top: 4.w, - child: Image.asset( - 'sc_images/room/rocket/rocket_body.png', - width: 31.w, - height: 38.w, - fit: BoxFit.contain, - gaplessPlayback: true, - ), - ), - ], - ), - ), - ), + child: _RoomRocketEntryButton( + iconUrl: level?.rocketIconUrl ?? '', + progressUrl: level?.progressBarUrl ?? '', + shake: shouldShake, + loading: status == null, ), ); }, ); } + SCRoomRocketLevelRes? _currentEntryLevel(SCRoomRocketStatusRes? status) { + final levels = status?.levels ?? const []; + if (levels.isEmpty) { + return null; + } + final currentLevel = (status?.currentLevel ?? status?.level?.toInt() ?? 1) + .clamp(1, 99); + for (final item in levels) { + if (item.level == currentLevel) { + return item; + } + } + final sorted = [...levels]..sort((a, b) { + final sortCompare = a.sort.compareTo(b.sort); + return sortCompare != 0 ? sortCompare : a.level.compareTo(b.level); + }); + return sorted.first; + } + + double _entryPercent(SCRoomRocketStatusRes? status) { + final value = status?.displayPercent ?? status?.energyPercent; + if (value == null) { + return 0; + } + final raw = value.toDouble(); + final percent = raw > 0 && raw <= 1 ? raw * 100 : raw; + return percent.clamp(0, 100).toDouble(); + } + void _showRocketDialog(BuildContext context, SCRoomRocketStatusRes? status) { final rtcProvider = Provider.of(context, listen: false); final roomId = @@ -104,6 +107,183 @@ class RoomRocketFloatingEntry extends StatelessWidget { } } +class _RoomRocketEntryButton extends StatefulWidget { + const _RoomRocketEntryButton({ + required this.iconUrl, + required this.progressUrl, + required this.shake, + required this.loading, + }); + + final String iconUrl; + final String progressUrl; + final bool shake; + final bool loading; + + @override + State<_RoomRocketEntryButton> createState() => _RoomRocketEntryButtonState(); +} + +class _RoomRocketEntryButtonState extends State<_RoomRocketEntryButton> + with SingleTickerProviderStateMixin { + late final AnimationController _shakeController; + + @override + void initState() { + super.initState(); + _shakeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 480), + ); + _syncShake(); + } + + @override + void didUpdateWidget(covariant _RoomRocketEntryButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.shake != widget.shake) { + _syncShake(); + } + } + + @override + void dispose() { + _shakeController.dispose(); + super.dispose(); + } + + void _syncShake() { + if (widget.shake) { + _shakeController.repeat(); + return; + } + _shakeController + ..stop() + ..value = 0; + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _shakeController, + builder: (context, child) { + final wave = math.sin(_shakeController.value * math.pi * 2); + return Transform.translate( + offset: widget.shake ? Offset(wave * 1.4.w, 0) : Offset.zero, + child: Transform.rotate( + angle: widget.shake ? wave * 0.065 : 0, + child: child, + ), + ); + }, + child: SizedBox( + width: 46.w, + height: 56.w, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 44.w, + height: 44.w, + child: _RocketEntryImage( + source: widget.iconUrl, + loading: widget.loading, + fit: BoxFit.contain, + ), + ), + SizedBox(height: 3.w), + SizedBox( + width: 42.w, + height: 8.w, + child: + widget.progressUrl.trim().isEmpty + ? const SizedBox.shrink() + : ClipRRect( + borderRadius: BorderRadius.circular(5.w), + child: _RocketEntryImage( + source: widget.progressUrl, + loading: false, + fit: BoxFit.fill, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _RocketEntryImage extends StatelessWidget { + const _RocketEntryImage({ + required this.source, + this.loading = false, + this.fit = BoxFit.contain, + }); + + final String source; + final bool loading; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final value = source.trim(); + if (value.isEmpty) { + return _placeholder(); + } + if (RoomRocketPagEffectOverlay.isPag(value)) { + return RoomRocketPagPreview( + resource: value, + loop: true, + defaultBuilder: (_) => _placeholder(), + ); + } + if (value.startsWith('sc_images/')) { + return Image.asset(value, fit: fit, gaplessPlayback: true); + } + if (_isInlineSvg(value)) { + return SvgPicture.string( + value, + fit: fit, + errorBuilder: (_, __, ___) => _placeholder(), + ); + } + if (_isSvg(value)) { + return SvgPicture.network( + value, + fit: fit, + errorBuilder: (_, __, ___) => _placeholder(), + ); + } + return CustomCachedImage( + imageUrl: value, + fit: fit, + placeholder: _placeholder(), + errorWidget: const SizedBox.shrink(), + ); + } + + static bool _isSvg(String value) { + final lower = value.toLowerCase(); + return lower.endsWith('.svg') || lower.contains('.svg?'); + } + + static bool _isInlineSvg(String value) { + final trimmed = value.trimLeft(); + return trimmed.startsWith(' { SCRoomRocketStatusRes? _status; List? _noteSections; + int _selectedLevel = 0; + RoomRocketRewardTab _selectedRewardTab = RoomRocketRewardTab.top1; + bool _levelTouched = false; @override void initState() { super.initState(); _status = widget.initialStatus; + _selectedLevel = _levelIndexFromStatus(widget.initialStatus); unawaited(_load()); unawaited(_loadRule()); } @@ -145,7 +329,13 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { if (!mounted) { return; } - setState(() => _status = res); + _debugStatusAssets(res); + setState(() { + _status = res; + if (!_levelTouched) { + _selectedLevel = _levelIndexFromStatus(res); + } + }); try { Provider.of( context, @@ -175,22 +365,63 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { @override Widget build(BuildContext context) { final status = _status; - final selectedLevel = - ((status?.currentLevel ?? status?.level ?? 1) - 1).clamp(0, 5).toInt(); + final selectedLevel = _selectedLevel.clamp(0, 5).toInt(); return RoomRocketDialog( status: status, levels: RoomRocketApiMapper.levelsFromStatus(status), selectedLevel: selectedLevel, + onLevelSelected: _onLevelSelected, rewards: RoomRocketApiMapper.rewardsFromStatus(status), + rewardGroups: RoomRocketApiMapper.rewardGroupsFromStatus(status), + selectedRewardTab: _selectedRewardTab, + onRewardTabSelected: _onRewardTabSelected, crew: RoomRocketApiMapper.crewFromStatus(status), onClose: widget.onClose, onOpenRank: () => _openRank(selectedLevel), - onRewardSelected: _openReward, onOpenRecord: _openRecord, onOpenNote: _openNote, ); } + int _levelIndexFromStatus(SCRoomRocketStatusRes? status) { + return ((status?.currentLevel ?? status?.level ?? 1) - 1) + .clamp(0, 5) + .toInt(); + } + + void _debugStatusAssets(SCRoomRocketStatusRes status) { + final levels = status.levels ?? const []; + _rocketUiDebug( + 'status assets loaded roomId=${status.roomId} levels=${levels.length} ' + 'currentLevel=${status.currentLevel} selectedIndex=${_levelIndexFromStatus(status)}', + ); + for (final item in levels) { + _rocketUiDebug( + 'level=${item.level} icon=${_shortRocketDebug(item.rocketIconUrl)} ' + 'centerPreview=${_shortRocketDebug(item.rocketPreviewUrl)} ' + 'animation=${_shortRocketDebug(item.rocketAnimationUrl)} ' + 'progress=${_shortRocketDebug(item.progressBarUrl)} ' + 'shakeThreshold=${item.shakeThresholdPercent}', + ); + } + } + + void _onLevelSelected(int index) { + _rocketUiDebug('select rocket level=${index + 1}'); + setState(() { + _levelTouched = true; + _selectedLevel = index.clamp(0, 5).toInt(); + }); + } + + void _onRewardTabSelected(RoomRocketRewardTab tab) { + if (_selectedRewardTab == tab) { + return; + } + _rocketUiDebug('select reward tab=${tab.name}'); + setState(() => _selectedRewardTab = tab); + } + void _openRank(int selectedLevel) { _rocketUiDebug( 'tap rocket king roomId=${widget.roomId} level=${selectedLevel + 1}', @@ -211,29 +442,6 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { }); } - void _openReward(RoomRocketRewardItem reward) { - _rocketUiDebug('tap reward preview roomId=${widget.roomId}'); - SmartDialog.dismiss(tag: RoomRocketFloatingEntry.dialogTag).then((_) { - SmartDialog.show( - tag: RoomRocketFloatingEntry.rewardDialogTag, - alignment: Alignment.center, - debounce: true, - animationType: SmartAnimationType.fade, - maskColor: Colors.transparent, - clickMaskDismiss: false, - builder: - (_) => _RoomRocketRewardDialogLoader( - roomId: widget.roomId, - selectedReward: reward, - onClose: - () => SmartDialog.dismiss( - tag: RoomRocketFloatingEntry.rewardDialogTag, - ), - ), - ); - }); - } - void _openRecord() { _rocketUiDebug('tap record roomId=${widget.roomId}'); SmartDialog.dismiss(tag: RoomRocketFloatingEntry.dialogTag).then((_) { @@ -279,69 +487,6 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { } } -class _RoomRocketRewardDialogLoader extends StatefulWidget { - const _RoomRocketRewardDialogLoader({ - required this.roomId, - required this.selectedReward, - this.onClose, - }); - - final String roomId; - final RoomRocketRewardItem selectedReward; - final VoidCallback? onClose; - - @override - State<_RoomRocketRewardDialogLoader> createState() => - _RoomRocketRewardDialogLoaderState(); -} - -class _RoomRocketRewardDialogLoaderState - extends State<_RoomRocketRewardDialogLoader> { - List? _rewards; - List? _winners; - bool _loading = true; - - @override - void initState() { - super.initState(); - unawaited(_load()); - } - - Future _load() async { - try { - _rocketUiDebug('request reward-popups roomId=${widget.roomId}'); - final res = await SCChatRoomRepository().roomRocketRewardPopups( - widget.roomId, - ); - if (!mounted) { - return; - } - setState(() { - _rewards = RoomRocketApiMapper.rewardItemsFromRecords(res.records); - _winners = RoomRocketApiMapper.winnersFromRecords(res.records); - _loading = false; - }); - } catch (error) { - _rocketUiDebug('reward-popups request failed: $error'); - if (mounted) { - setState(() => _loading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return RoomRocketRewardDialog( - selectedReward: widget.selectedReward, - rewards: _rewards, - winners: _winners, - loadingText: _loading ? 'Loading data......' : '', - useMockFallback: false, - onClose: widget.onClose, - ); - } -} - class _RoomRocketRecordDialogLoader extends StatefulWidget { const _RoomRocketRecordDialogLoader({required this.roomId, this.onClose}); @@ -355,32 +500,74 @@ class _RoomRocketRecordDialogLoader extends StatefulWidget { class _RoomRocketRecordDialogLoaderState extends State<_RoomRocketRecordDialogLoader> { + final List _rawRecords = []; List? _records; bool _loading = true; + bool _loadingMore = false; + bool _hasMore = true; + int _cursor = 1; @override void initState() { super.initState(); - unawaited(_load()); + unawaited(_load(reset: true)); } - Future _load() async { + Future _load({bool reset = false}) async { + if (_loadingMore || (!reset && (!_hasMore || _loading))) { + return; + } + final requestCursor = reset ? 1 : _cursor; + setState(() { + if (reset) { + _loading = true; + } else { + _loadingMore = true; + } + }); try { - _rocketUiDebug('request reward-records roomId=${widget.roomId}'); + _rocketUiDebug( + 'request reward-records roomId=${widget.roomId} ' + 'cursor=$requestCursor limit=$_rocketPageSize', + ); final res = await SCChatRoomRepository().roomRocketRewardRecords( widget.roomId, + cursor: requestCursor, + limit: _rocketPageSize, ); if (!mounted) { return; } + final nextRecords = res.records; setState(() { - _records = RoomRocketApiMapper.recordItemsFromRecords(res.records); + if (reset) { + _rawRecords + ..clear() + ..addAll(nextRecords); + } else { + _rawRecords.addAll(nextRecords); + } + _records = RoomRocketApiMapper.recordItemsFromRecords(_rawRecords); + _hasMore = nextRecords.length >= _rocketPageSize; + if (_hasMore) { + _cursor = requestCursor + 1; + } _loading = false; + _loadingMore = false; }); + _rocketUiDebug( + 'reward-records loaded cursor=$requestCursor ' + 'count=${nextRecords.length} total=${_rawRecords.length} ' + 'hasMore=$_hasMore nextCursor=$_cursor', + ); } catch (error) { _rocketUiDebug('reward-records request failed: $error'); if (mounted) { - setState(() => _loading = false); + setState(() { + _loading = false; + _loadingMore = false; + _hasMore = false; + }); } } } @@ -391,6 +578,9 @@ class _RoomRocketRecordDialogLoaderState records: _records, useMockFallback: false, loadingText: _loading ? 'Loading data......' : '', + isLoadingMore: _loadingMore, + hasMore: _hasMore, + onLoadMore: () => unawaited(_load()), limitText: 'Show only records from the last 35 days', onClose: widget.onClose, ); @@ -448,3 +638,14 @@ void _rocketUiDebug(String message) { debugPrint('[RoomRocketUi] $message'); } } + +String _shortRocketDebug(String value) { + final text = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (text.isEmpty) { + return '-'; + } + if (text.length <= 140) { + return text; + } + return '${text.substring(0, 137)}...'; +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart new file mode 100644 index 0000000..34c31eb --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart @@ -0,0 +1,243 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pag/pag.dart'; +import 'package:yumi/ui_kit/components/custom_cached_image.dart'; +import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; + +class RoomRocketPagEffectOverlay extends StatefulWidget { + const RoomRocketPagEffectOverlay({ + super.key, + required this.resource, + this.topAvatarUrl, + this.onCompleted, + }); + + final String resource; + final String? topAvatarUrl; + final VoidCallback? onCompleted; + + static bool isPag(String? resource) { + final lower = resource?.trim().toLowerCase() ?? ''; + return lower.contains('.pag'); + } + + @override + State createState() => + _RoomRocketPagEffectOverlayState(); +} + +class _RoomRocketPagEffectOverlayState + extends State { + bool _completed = false; + + @override + Widget build(BuildContext context) { + final resource = widget.resource.trim(); + final size = MediaQuery.sizeOf(context); + return IgnorePointer( + child: ColoredBox( + color: Colors.transparent, + child: Center( + child: SizedBox( + width: size.width, + height: size.height, + child: Stack( + clipBehavior: Clip.none, + children: [ + _RoomRocketPagTopAvatar( + avatarUrl: widget.topAvatarUrl, + canvasSize: size, + ), + Positioned.fill( + child: _RoomRocketPagView( + resource: resource, + width: size.width, + height: size.height, + loop: false, + onCompleted: _complete, + defaultBuilder: + (_) => const Center(child: SCRotatingDotsLoading()), + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _complete() { + if (_completed) { + return; + } + _completed = true; + widget.onCompleted?.call(); + } +} + +class _RoomRocketPagTopAvatar extends StatelessWidget { + const _RoomRocketPagTopAvatar({ + required this.avatarUrl, + required this.canvasSize, + }); + + final String? avatarUrl; + final Size canvasSize; + + @override + Widget build(BuildContext context) { + final url = avatarUrl?.trim() ?? ''; + if (url.isEmpty) { + return const SizedBox.shrink(); + } + final size = (canvasSize.width * 0.15).clamp(46.0, 66.0).toDouble(); + return Positioned( + left: (canvasSize.width - size) / 2, + top: canvasSize.height * 0.098, + width: size, + height: size, + child: ClipOval( + child: CustomCachedImage( + imageUrl: url, + fit: BoxFit.cover, + placeholder: const SizedBox.shrink(), + errorWidget: const SizedBox.shrink(), + ), + ), + ); + } +} + +class RoomRocketPagPreview extends StatelessWidget { + const RoomRocketPagPreview({ + super.key, + required this.resource, + this.width, + this.height, + this.loop = true, + this.defaultBuilder, + }); + + final String resource; + final double? width; + final double? height; + final bool loop; + final WidgetBuilder? defaultBuilder; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final resolvedWidth = + width ?? + (constraints.maxWidth.isFinite ? constraints.maxWidth : null); + final resolvedHeight = + height ?? + (constraints.maxHeight.isFinite ? constraints.maxHeight : null); + return _RoomRocketPagView( + resource: resource, + width: resolvedWidth, + height: resolvedHeight, + loop: loop, + defaultBuilder: + defaultBuilder ?? + (_) => const Center(child: SCRotatingDotsLoading()), + ); + }, + ); + } +} + +class _RoomRocketPagView extends StatefulWidget { + const _RoomRocketPagView({ + required this.resource, + this.width, + this.height, + required this.loop, + this.onCompleted, + this.defaultBuilder, + }); + + final String resource; + final double? width; + final double? height; + final bool loop; + final VoidCallback? onCompleted; + final WidgetBuilder? defaultBuilder; + + @override + State<_RoomRocketPagView> createState() => _RoomRocketPagViewState(); +} + +class _RoomRocketPagViewState extends State<_RoomRocketPagView> { + late Future _bytesFuture; + + @override + void initState() { + super.initState(); + _bytesFuture = _loadPagBytes(widget.resource.trim()); + } + + @override + void didUpdateWidget(covariant _RoomRocketPagView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.resource.trim() != widget.resource.trim()) { + _bytesFuture = _loadPagBytes(widget.resource.trim()); + } + } + + @override + Widget build(BuildContext context) { + final path = widget.resource.trim(); + if (path.isEmpty || !RoomRocketPagEffectOverlay.isPag(path)) { + return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); + } + final repeatCount = + widget.loop ? PAGView.REPEAT_COUNT_LOOP : PAGView.REPEAT_COUNT_DEFAULT; + final defaultChild = + widget.defaultBuilder?.call(context) ?? + const Center(child: SCRotatingDotsLoading()); + return FutureBuilder( + future: _bytesFuture, + builder: (context, snapshot) { + final bytes = snapshot.data; + if (snapshot.connectionState != ConnectionState.done || + bytes == null || + bytes.isEmpty) { + return defaultChild; + } + return PAGView.bytes( + bytes, + key: ValueKey('room-rocket-pag:$path:${widget.loop}'), + width: widget.width, + height: widget.height, + autoPlay: true, + repeatCount: repeatCount, + onAnimationEnd: widget.onCompleted, + defaultBuilder: (_) => defaultChild, + ); + }, + ); + } + + Future _loadPagBytes(String path) async { + try { + final data = + _isNetwork(path) + ? await NetworkAssetBundle(Uri.parse(path)).load(path) + : await rootBundle.load(path); + return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + } catch (error) { + if (kDebugMode) { + debugPrint('[RoomRocketPag] load failed path=$path error=$error'); + } + return null; + } + } + + static bool _isNetwork(String value) { + return value.startsWith('http://') || value.startsWith('https://'); + } +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart index c5b2229..79cc022 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart'; @@ -10,6 +11,9 @@ class RoomRocketRecordDialog extends StatelessWidget { this.records, this.loadingText = 'Loading data......', this.limitText = 'Show only records from the last 35 days', + this.isLoadingMore = false, + this.hasMore = false, + this.onLoadMore, this.useMockFallback = true, this.onClose, }); @@ -17,11 +21,21 @@ class RoomRocketRecordDialog extends StatelessWidget { final List? records; final String loadingText; final String limitText; + final bool isLoadingMore; + final bool hasMore; + final VoidCallback? onLoadMore; final bool useMockFallback; final VoidCallback? onClose; @override Widget build(BuildContext context) { + final localizations = SCAppLocalizations.of(context); + final emptyText = _translate( + localizations, + 'roomRocketRecordEmptyText', + 'No reward records', + ); + final contentDirection = Directionality.of(context); final resolvedRecords = _normalizeRecords( records, useMockFallback: useMockFallback, @@ -57,6 +71,11 @@ class RoomRocketRecordDialog extends StatelessWidget { records: resolvedRecords, loadingText: loadingText, limitText: limitText, + isLoadingMore: isLoadingMore, + hasMore: hasMore, + onLoadMore: onLoadMore, + emptyText: emptyText, + contentDirection: contentDirection, onClose: onClose, ), ), @@ -100,6 +119,18 @@ class RoomRocketRecordDialog extends StatelessWidget { growable: false, ); } + + static String _translate( + SCAppLocalizations? localizations, + String key, + String fallback, + ) { + final value = localizations?.translate(key); + if (value == null || value == key) { + return fallback; + } + return value; + } } class RoomRocketRecordItem { @@ -122,6 +153,11 @@ class _RecordDialogCanvas extends StatelessWidget { required this.records, required this.loadingText, required this.limitText, + required this.isLoadingMore, + required this.hasMore, + this.onLoadMore, + required this.emptyText, + required this.contentDirection, this.onClose, }); @@ -129,19 +165,26 @@ class _RecordDialogCanvas extends StatelessWidget { final List records; final String loadingText; final String limitText; + final bool isLoadingMore; + final bool hasMore; + final VoidCallback? onLoadMore; + final String emptyText; + final TextDirection contentDirection; final VoidCallback? onClose; double s(double value) => value * scale; @override Widget build(BuildContext context) { + final isLoading = loadingText.trim().isNotEmpty; + final isEmpty = !isLoading && records.isEmpty; return Stack( clipBehavior: Clip.hardEdge, children: [ _buildPanelBackground(), _buildHeader(), _buildColumnTitles(), - _buildRecordList(), + if (isEmpty) _buildEmptyState() else _buildRecordList(), _buildFooter(), _buildCloseButton(), ], @@ -227,21 +270,81 @@ class _RecordDialogCanvas extends StatelessWidget { child: ClipRect( child: ScrollConfiguration( behavior: const _NoGlowScrollBehavior(), - child: ListView.builder( - padding: EdgeInsets.zero, - primary: false, - physics: const BouncingScrollPhysics(), - itemExtent: s(61), - itemCount: records.length, - itemBuilder: (context, index) { - return _RecordRow(scale: scale, item: records[index]); - }, + child: NotificationListener( + onNotification: _handleScrollNotification, + child: ListView.builder( + padding: EdgeInsets.zero, + primary: false, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + itemExtent: s(61), + itemCount: records.length + (isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= records.length) { + return _LoadMoreRow(scale: scale); + } + return _RecordRow(scale: scale, item: records[index]); + }, + ), ), ), ), ); } + bool _handleScrollNotification(ScrollNotification notification) { + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (!hasMore || isLoadingMore) { + return false; + } + if (notification.metrics.extentAfter <= s(96)) { + onLoadMore?.call(); + } + return false; + } + + Widget _buildEmptyState() { + return Positioned( + left: s(0), + top: s(340), + width: s(_designWidth), + height: s(190), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + _RecordDialogAssets.empty, + width: s(86), + height: s(86), + fit: BoxFit.contain, + ), + SizedBox(height: s(12)), + SizedBox( + width: s(268), + child: Text( + emptyText, + textDirection: contentDirection, + textScaler: TextScaler.noScaling, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: s(14), + height: 1.25, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ); + } + Widget _buildFooter() { return Stack( children: [ @@ -391,7 +494,9 @@ class _RecordRow extends StatelessWidget { child: ClipRect( child: SingleChildScrollView( scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), child: Row( children: [ for (var index = 0; index < rewards.length; index++) ...[ @@ -429,28 +534,25 @@ class _SmallReward extends StatelessWidget { top: 0, width: 28 * scale, height: 28 * scale, - child: _RocketImage( - asset: item.asset ?? _RecordDialogAssets.rewardThumb, - imageUrl: item.imageUrl, - fit: BoxFit.cover, - ), + child: + item.isGoldReward + ? Image.asset(_RecordDialogAssets.coin, fit: BoxFit.contain) + : _RocketImage( + asset: item.asset ?? _RecordDialogAssets.rewardThumb, + imageUrl: item.imageUrl, + fit: BoxFit.contain, + ), ), Positioned( - left: 0, + left: -3 * scale, top: 30 * scale, - width: 8 * scale, - height: 8 * scale, - child: Image.asset(_RecordDialogAssets.coin, fit: BoxFit.contain), - ), - Positioned( - left: 8 * scale, - top: 30 * scale, - width: 26 * scale, + width: 34 * scale, child: Text( - item.amount, + item.displayText, textScaler: TextScaler.noScaling, maxLines: 1, - overflow: TextOverflow.visible, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, style: TextStyle( color: Colors.white, fontSize: 8 * scale, @@ -671,6 +773,30 @@ class _NoGlowScrollBehavior extends ScrollBehavior { } } +class _LoadMoreRow extends StatelessWidget { + const _LoadMoreRow({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + 'Loading data......', + textScaler: TextScaler.noScaling, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color(0xFF78FF78).withValues(alpha: 0.56), + fontSize: 10 * scale, + height: 1, + decoration: TextDecoration.none, + ), + ), + ); + } +} + class _RecordDialogAssets { const _RecordDialogAssets._(); @@ -681,6 +807,7 @@ class _RecordDialogAssets { 'sc_images/room/rocket/reward_dialog_bottom.png'; static const String rewardThumb = 'sc_images/room/rocket/reward_thumb.png'; static const String coin = 'sc_images/register_reward/yumi_coin.png'; + static const String empty = 'sc_images/room/sc_icon_room_music_empty.png'; } const double _designWidth = 375; diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart index ab6d8f4..b9db84e 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart'; @@ -11,6 +12,9 @@ class RoomRocketRewardDialog extends StatelessWidget { this.rewards, this.winners, this.loadingText = 'Loading data......', + this.isLoadingMore = false, + this.hasMore = false, + this.onLoadMore, this.useMockFallback = true, this.onClose, }); @@ -19,11 +23,21 @@ class RoomRocketRewardDialog extends StatelessWidget { final List? rewards; final List? winners; final String loadingText; + final bool isLoadingMore; + final bool hasMore; + final VoidCallback? onLoadMore; final bool useMockFallback; final VoidCallback? onClose; @override Widget build(BuildContext context) { + final localizations = SCAppLocalizations.of(context); + final emptyText = _translate( + localizations, + 'roomRocketRewardEmptyText', + 'No rewards earned. Better luck next time.', + ); + final contentDirection = Directionality.of(context); final resolvedRewards = _normalizeRewards( selectedReward, rewards, @@ -65,6 +79,11 @@ class RoomRocketRewardDialog extends StatelessWidget { rewards: resolvedRewards, winners: resolvedWinners, loadingText: loadingText, + isLoadingMore: isLoadingMore, + hasMore: hasMore, + onLoadMore: onLoadMore, + emptyText: emptyText, + contentDirection: contentDirection, onClose: onClose, ), ), @@ -89,7 +108,7 @@ class RoomRocketRewardDialog extends StatelessWidget { if (source.isNotEmpty) { return source; } - if (!useMockFallback && selectedReward == null) { + if (!useMockFallback) { return const []; } final fallback = @@ -153,6 +172,18 @@ class RoomRocketRewardDialog extends StatelessWidget { ); }, growable: false); } + + static String _translate( + SCAppLocalizations? localizations, + String key, + String fallback, + ) { + final value = localizations?.translate(key); + if (value == null || value == key) { + return fallback; + } + return value; + } } class RoomRocketRewardWinner { @@ -187,6 +218,11 @@ class _RewardDialogCanvas extends StatelessWidget { required this.rewards, required this.winners, required this.loadingText, + required this.isLoadingMore, + required this.hasMore, + this.onLoadMore, + required this.emptyText, + required this.contentDirection, this.onClose, }); @@ -194,19 +230,30 @@ class _RewardDialogCanvas extends StatelessWidget { final List rewards; final List winners; final String loadingText; + final bool isLoadingMore; + final bool hasMore; + final VoidCallback? onLoadMore; + final String emptyText; + final TextDirection contentDirection; final VoidCallback? onClose; double s(double value) => value * scale; @override Widget build(BuildContext context) { + final isLoading = loadingText.trim().isNotEmpty; + final isEmpty = !isLoading && rewards.isEmpty && winners.isEmpty; return Stack( clipBehavior: Clip.hardEdge, children: [ _buildPanelBackground(), - _buildHeader(), - _buildRewardGrid(), - _buildWinnerList(), + _buildHeader(showSections: !isEmpty), + if (isEmpty) + _buildEmptyState() + else ...[ + _buildRewardGrid(), + _buildWinnerList(), + ], _buildLoadingText(), _buildCloseButton(), ], @@ -233,7 +280,7 @@ class _RewardDialogCanvas extends StatelessWidget { ); } - Widget _buildHeader() { + Widget _buildHeader({required bool showSections}) { return Stack( children: [ Positioned( @@ -258,48 +305,112 @@ class _RewardDialogCanvas extends StatelessWidget { ), ), ), - Positioned( - left: s(0), - top: s(230), - width: s(_designWidth), - child: _SectionTitle( - text: 'Congratulations on winning', - scale: scale, + if (showSections) ...[ + Positioned( + left: s(0), + top: s(230), + width: s(_designWidth), + child: _SectionTitle( + text: 'Congratulations on winning', + scale: scale, + ), ), - ), - Positioned( - left: s(0), - top: s(482), - width: s(_designWidth), - child: _SectionTitle(text: '12 users received rewards', scale: scale), - ), + Positioned( + left: s(0), + top: s(482), + width: s(_designWidth), + child: _SectionTitle( + text: '12 users received rewards', + scale: scale, + ), + ), + ], ], ); } + Widget _buildEmptyState() { + return Positioned( + left: s(0), + top: s(322), + width: s(_designWidth), + height: s(250), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + _RewardDialogAssets.empty, + width: s(86), + height: s(86), + fit: BoxFit.contain, + ), + SizedBox(height: s(12)), + SizedBox( + width: s(268), + child: Text( + emptyText, + textDirection: contentDirection, + textScaler: TextScaler.noScaling, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: s(14), + height: 1.25, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ); + } + Widget _buildRewardGrid() { if (rewards.isEmpty) { return const SizedBox.shrink(); } - final visibleRewards = - rewards.length >= 6 - ? rewards.take(6).toList(growable: false) - : List.generate( - 6, - (index) => rewards[index % rewards.length], - growable: false, - ); - return Stack( - children: [ - for (var index = 0; index < visibleRewards.length; index++) - Positioned( - left: s(28 + (index % 3) * 112), - top: s(index < 3 ? 256 : 365), - width: s(97), - height: s(101), - child: _RewardDetailCard(scale: scale, item: visibleRewards[index]), + final columns = math.max(3, (rewards.length / 2).ceil()); + return Positioned( + left: s(28), + top: s(256), + width: s(336), + height: s(210), + child: ClipRect( + child: ScrollConfiguration( + behavior: const _NoGlowScrollBehavior(), + child: NotificationListener( + onNotification: _handleHorizontalScrollNotification, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + child: SizedBox( + width: s(columns * 112), + height: s(210), + child: Stack( + children: [ + for (var index = 0; index < rewards.length; index++) + Positioned( + left: s((index ~/ 2) * 112), + top: s(index.isEven ? 0 : 109), + width: s(97), + height: s(101), + child: _RewardDetailCard( + scale: scale, + item: rewards[index], + ), + ), + ], + ), + ), + ), ), - ], + ), + ), ); } @@ -312,25 +423,59 @@ class _RewardDialogCanvas extends StatelessWidget { child: ClipRect( child: ScrollConfiguration( behavior: const _NoGlowScrollBehavior(), - child: ListView.builder( - padding: EdgeInsets.zero, - primary: false, - physics: const BouncingScrollPhysics(), - itemExtent: s(51), - itemCount: winners.length, - itemBuilder: (context, index) { - return _WinnerRow( - scale: scale, - winner: winners[index], - showRocketBadge: index == 0, - ); - }, + child: NotificationListener( + onNotification: _handleScrollNotification, + child: ListView.builder( + padding: EdgeInsets.zero, + primary: false, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + itemExtent: s(51), + itemCount: winners.length + (isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= winners.length) { + return _LoadMoreRow(scale: scale); + } + return _WinnerRow( + scale: scale, + winner: winners[index], + showRocketBadge: index == 0, + ); + }, + ), ), ), ), ); } + bool _handleScrollNotification(ScrollNotification notification) { + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (!hasMore || isLoadingMore) { + return false; + } + if (notification.metrics.extentAfter <= s(80)) { + onLoadMore?.call(); + } + return false; + } + + bool _handleHorizontalScrollNotification(ScrollNotification notification) { + if (notification.metrics.axis != Axis.horizontal) { + return false; + } + if (!hasMore || isLoadingMore) { + return false; + } + if (notification.metrics.extentAfter <= s(96)) { + onLoadMore?.call(); + } + return false; + } + Widget _buildLoadingText() { return Positioned( left: 0, @@ -421,28 +566,25 @@ class _RewardDetailCard extends StatelessWidget { top: 11 * scale, width: 50 * scale, height: 50 * scale, - child: _RocketImage( - asset: item.asset ?? _RewardDialogAssets.rewardThumb, - imageUrl: item.imageUrl, - fit: BoxFit.cover, - ), + child: + item.isGoldReward + ? Image.asset(_RewardDialogAssets.coin, fit: BoxFit.contain) + : _RocketImage( + asset: item.asset ?? _RewardDialogAssets.rewardThumb, + imageUrl: item.imageUrl, + fit: BoxFit.contain, + ), ), Positioned( - left: 25 * scale, - top: 74 * scale, - width: 12 * scale, - height: 12 * scale, - child: Image.asset(_RewardDialogAssets.coin, fit: BoxFit.contain), - ), - Positioned( - left: 39 * scale, + left: 21 * scale, top: 73 * scale, - width: 44 * scale, + width: 56 * scale, child: Text( - item.amount, + item.displayText, textScaler: TextScaler.noScaling, maxLines: 1, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, style: TextStyle( color: Colors.white, fontSize: 12 * scale, @@ -469,7 +611,7 @@ class _WinnerRow extends StatelessWidget { @override Widget build(BuildContext context) { - final rewards = winner.rewards.take(4).toList(growable: false); + final rewards = winner.rewards; return SizedBox( height: 51 * scale, child: Stack( @@ -522,14 +664,33 @@ class _WinnerRow extends StatelessWidget { ), ), ), - for (var index = 0; index < rewards.length; index++) - Positioned( - left: (202 + index * 36) * scale, - top: 2 * scale, - width: 27 * scale, - height: 32 * scale, - child: _SmallReward(scale: scale, item: rewards[index]), + Positioned( + left: 202 * scale, + top: 2 * scale, + width: 134 * scale, + height: 36 * scale, + child: ClipRect( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + child: Row( + children: [ + for (var index = 0; index < rewards.length; index++) ...[ + SizedBox( + width: 27 * scale, + height: 32 * scale, + child: _SmallReward(scale: scale, item: rewards[index]), + ), + if (index != rewards.length - 1) + SizedBox(width: 9 * scale), + ], + ], + ), + ), ), + ), ], ), ); @@ -552,28 +713,25 @@ class _SmallReward extends StatelessWidget { top: 0, width: 22 * scale, height: 22 * scale, - child: _RocketImage( - asset: item.asset ?? _RewardDialogAssets.rewardThumb, - imageUrl: item.imageUrl, - fit: BoxFit.cover, - ), + child: + item.isGoldReward + ? Image.asset(_RewardDialogAssets.coin, fit: BoxFit.contain) + : _RocketImage( + asset: item.asset ?? _RewardDialogAssets.rewardThumb, + imageUrl: item.imageUrl, + fit: BoxFit.contain, + ), ), Positioned( - left: 0, - top: 25 * scale, - width: 6 * scale, - height: 6 * scale, - child: Image.asset(_RewardDialogAssets.coin, fit: BoxFit.contain), - ), - Positioned( - left: 7 * scale, + left: -1 * scale, top: 24 * scale, - width: 22 * scale, + width: 29 * scale, child: Text( - _compactAmount(item.amount), + _compactAmount(item.displayText), textScaler: TextScaler.noScaling, maxLines: 1, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, style: TextStyle( color: Colors.white, fontSize: 8 * scale, @@ -795,6 +953,30 @@ class _NoGlowScrollBehavior extends ScrollBehavior { } } +class _LoadMoreRow extends StatelessWidget { + const _LoadMoreRow({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + 'Loading data......', + textScaler: TextScaler.noScaling, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color(0xFF78FF78).withValues(alpha: 0.56), + fontSize: 10 * scale, + height: 1, + decoration: TextDecoration.none, + ), + ), + ); + } +} + class _RewardDetailCardPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { @@ -917,6 +1099,7 @@ class _RewardDialogAssets { 'sc_images/room/rocket/reward_dialog_bottom.png'; static const String rewardThumb = 'sc_images/room/rocket/reward_thumb.png'; static const String coin = 'sc_images/register_reward/yumi_coin.png'; + static const String empty = 'sc_images/room/sc_icon_room_music_empty.png'; static const String avatar1 = 'sc_images/room/rocket/rocket_rank_frame_1.png'; static const String avatar2 = 'sc_images/room/rocket/rocket_rank_frame_2.png'; static const String avatar3 = 'sc_images/room/rocket/rocket_rank_frame_3.png'; diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart new file mode 100644 index 0000000..be89a38 --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart'; + +const int _rewardDialogPageSize = 10; + +class RoomRocketRewardDialogLoader extends StatefulWidget { + const RoomRocketRewardDialogLoader({ + super.key, + required this.roomId, + this.selectedReward, + this.initialPopupRecords = const [], + this.onClose, + }); + + final String roomId; + final RoomRocketRewardItem? selectedReward; + final List initialPopupRecords; + final VoidCallback? onClose; + + @override + State createState() => + _RoomRocketRewardDialogLoaderState(); +} + +class _RoomRocketRewardDialogLoaderState + extends State { + final List _popupRecords = []; + final List _roomRecords = []; + List? _rewards; + List? _winners; + bool _loading = true; + bool _loadingMore = false; + bool _hasMore = true; + bool _usedInitialPopupRecords = false; + int _cursor = 1; + + @override + void initState() { + super.initState(); + if (widget.initialPopupRecords.isNotEmpty) { + _popupRecords.addAll(widget.initialPopupRecords); + _rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords); + _winners = RoomRocketApiMapper.winnersFromRecords(_popupRecords); + } + unawaited(_load(reset: true)); + } + + Future _load({bool reset = false}) async { + if (_loadingMore || (!reset && (!_hasMore || _loading))) { + return; + } + final requestCursor = reset ? 1 : _cursor; + setState(() { + if (reset) { + _loading = true; + } else { + _loadingMore = true; + } + }); + try { + final useInitialPopupRecords = + reset && + widget.initialPopupRecords.isNotEmpty && + !_usedInitialPopupRecords; + final nextPopupRecords = + useInitialPopupRecords + ? widget.initialPopupRecords + : await _loadPopupRecords(requestCursor); + if (useInitialPopupRecords) { + _usedInitialPopupRecords = true; + } + final nextRoomRecords = await _loadRoomRewardRecords(requestCursor); + if (!mounted) { + return; + } + setState(() { + if (reset) { + _popupRecords + ..clear() + ..addAll(nextPopupRecords); + _roomRecords + ..clear() + ..addAll(nextRoomRecords); + } else { + _popupRecords.addAll(nextPopupRecords); + _roomRecords.addAll(nextRoomRecords); + } + _rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords); + _winners = RoomRocketApiMapper.winnersFromRecords( + _visibleRoomRewardRecords(), + ); + _hasMore = + nextPopupRecords.length >= _rewardDialogPageSize || + nextRoomRecords.length >= _rewardDialogPageSize; + if (_hasMore) { + _cursor = requestCursor + 1; + } + _loading = false; + _loadingMore = false; + }); + _rewardDialogDebug( + 'loaded cursor=$requestCursor ' + 'popupCount=${nextPopupRecords.length} ' + 'popupTotal=${_popupRecords.length} ' + 'roomCount=${nextRoomRecords.length} ' + 'roomTotal=${_roomRecords.length} ' + 'visibleRoomTotal=${_visibleRoomRewardRecords().length} ' + 'hasMore=$_hasMore nextCursor=$_cursor', + ); + } catch (error) { + _rewardDialogDebug('request failed: $error'); + if (mounted) { + setState(() { + _loading = false; + _loadingMore = false; + _hasMore = false; + }); + } + } + } + + Future> _loadPopupRecords( + int cursor, + ) async { + _rewardDialogDebug( + 'request reward-popups roomId=${widget.roomId} ' + 'cursor=$cursor limit=$_rewardDialogPageSize', + ); + final res = await SCChatRoomRepository().roomRocketRewardPopups( + widget.roomId, + cursor: cursor, + limit: _rewardDialogPageSize, + ); + return res.records; + } + + Future> _loadRoomRewardRecords( + int cursor, + ) async { + if (widget.roomId.trim().isEmpty) { + return const []; + } + try { + _rewardDialogDebug( + 'request reward-room-records roomId=${widget.roomId} ' + 'cursor=$cursor limit=$_rewardDialogPageSize', + ); + final res = await SCChatRoomRepository().roomRocketRewardRecords( + widget.roomId, + cursor: cursor, + limit: _rewardDialogPageSize, + ); + return res.records; + } catch (error) { + _rewardDialogDebug('reward-room-records request failed: $error'); + return const []; + } + } + + List _visibleRoomRewardRecords() { + if (_roomRecords.isEmpty) { + return _popupRecords; + } + final launchNos = + _popupRecords + .map((item) => item.launchNo.trim()) + .where((item) => item.isNotEmpty) + .toSet(); + if (launchNos.isEmpty) { + return _roomRecords; + } + final matched = _roomRecords + .where((item) => launchNos.contains(item.launchNo.trim())) + .toList(growable: false); + return matched.isEmpty ? _roomRecords : matched; + } + + @override + Widget build(BuildContext context) { + return RoomRocketRewardDialog( + selectedReward: widget.selectedReward, + rewards: _rewards, + winners: _winners, + loadingText: _loading ? 'Loading data......' : '', + isLoadingMore: _loadingMore, + hasMore: _hasMore, + onLoadMore: () => unawaited(_load()), + useMockFallback: false, + onClose: widget.onClose, + ); + } +} + +void _rewardDialogDebug(String message) { + if (!kDebugMode) { + return; + } + debugPrint('[RoomRocketRewardDialog] $message'); +} diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index 3eb6936..321ed06 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -20,6 +20,7 @@ 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/badge/sc_user_badge_strip.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'; @@ -1167,31 +1168,25 @@ class _MsgItemState extends State { ); } - Widget _buildMedals(List wearBadge) { - return wearBadge.isNotEmpty - ? Expanded( - child: SingleChildScrollView( - child: SizedBox( - height: 25.w, - child: ListView.separated( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - itemCount: wearBadge.length, - itemBuilder: (context, index) { - return netImage( - width: 25.w, - height: 25.w, - url: wearBadge[index].selectUrl ?? "", - ); - }, - separatorBuilder: (BuildContext context, int index) { - return SizedBox(width: 5.w); - }, - ), - ), - ), - ) - : Container(); + Widget _buildMedals({ + required String? userId, + List fallbackBadges = const [], + }) { + final hasUserId = (userId ?? "").trim().isNotEmpty; + if (!hasUserId && fallbackBadges.isEmpty) { + return Container(); + } + return Expanded( + child: SCUserBadgeStrip( + userId: userId, + fallbackBadges: fallbackBadges, + height: 25.w, + badgeHeight: 25.w, + longBadgeWidth: 55.w, + spacing: 5.w, + maxBadges: 8, + ), + ); } int _msgColor(String type) { @@ -1773,10 +1768,12 @@ class _MsgItemState extends State { ) : Container(), _buildMedals( - (widget.msg.user?.wearBadge?.where((item) { - return item.use ?? false; - }).toList() ?? - []), + userId: widget.msg.user?.id, + fallbackBadges: + widget.msg.user?.wearBadge + ?.where((item) => item.use ?? false) + .toList() ?? + const [], ), ], ), @@ -1853,10 +1850,12 @@ class _MsgItemState extends State { ) : Container(), _buildMedals( - user.wearBadge?.where((item) { - return item.use ?? false; - }).toList() ?? - [], + userId: user.id, + fallbackBadges: + user.wearBadge + ?.where((item) => item.use ?? false) + .toList() ?? + const [], ), ], ), diff --git a/lib/ui_kit/widgets/room/room_password_dialog.dart b/lib/ui_kit/widgets/room/room_password_dialog.dart new file mode 100644 index 0000000..c6d62be --- /dev/null +++ b/lib/ui_kit/widgets/room/room_password_dialog.dart @@ -0,0 +1,267 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; + +typedef RoomPasswordSubmit = Future Function(String password); + +class RoomPasswordDialog extends StatefulWidget { + static const String dialogTag = 'showRoomPasswordDialog'; + + const RoomPasswordDialog({ + super.key, + required this.onSubmit, + required this.onCancel, + required this.onSuccess, + }); + + final RoomPasswordSubmit onSubmit; + final VoidCallback onCancel; + final ValueChanged onSuccess; + + static Future show({required RoomPasswordSubmit onSubmit}) { + final completer = Completer(); + var isCompleted = false; + + void complete(String? password) { + if (isCompleted) { + return; + } + isCompleted = true; + completer.complete(password); + } + + SmartDialog.dismiss(tag: dialogTag); + SmartDialog.show( + tag: dialogTag, + alignment: Alignment.center, + animationType: SmartAnimationType.fade, + backType: SmartBackType.block, + clickMaskDismiss: false, + maskColor: Colors.black.withValues(alpha: 0.58), + onDismiss: () { + complete(null); + }, + builder: (_) { + return RoomPasswordDialog( + onSubmit: onSubmit, + onCancel: () { + complete(null); + SmartDialog.dismiss(tag: dialogTag); + }, + onSuccess: (password) { + complete(password); + SmartDialog.dismiss(tag: dialogTag); + }, + ); + }, + ); + + return completer.future; + } + + @override + State createState() => _RoomPasswordDialogState(); +} + +class _RoomPasswordDialogState extends State { + final TextEditingController _passwordController = TextEditingController(); + String _errorText = ""; + bool _isSubmitting = false; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _handleEnter() async { + if (_isSubmitting) { + return; + } + final password = _passwordController.text.trim(); + if (password.isEmpty) { + setState(() { + _errorText = "Please enter password"; + }); + return; + } + + setState(() { + _errorText = ""; + _isSubmitting = true; + }); + final errorText = await widget.onSubmit(password); + if (!mounted) { + return; + } + if ((errorText ?? "").trim().isNotEmpty) { + setState(() { + _errorText = errorText!.trim(); + _isSubmitting = false; + }); + return; + } + widget.onSuccess(password); + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + width: 316.w, + padding: EdgeInsets.fromLTRB(18.w, 18.w, 18.w, 16.w), + decoration: BoxDecoration( + color: const Color(0xff09372E), + borderRadius: BorderRadius.circular(12.w), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Room lock", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w600, + height: 1, + ), + ), + SizedBox(height: 18.w), + _buildPasswordInput(), + SizedBox(height: _errorText.isEmpty ? 14.w : 8.w), + if (_errorText.isNotEmpty) _buildErrorText(), + SizedBox(height: 12.w), + Row( + children: [ + Expanded( + child: _buildButton( + label: "cancel", + filled: false, + onTap: _isSubmitting ? null : widget.onCancel, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _buildButton( + label: "enter", + filled: true, + onTap: _isSubmitting ? null : _handleEnter, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildPasswordInput() { + return Container( + height: 44.w, + decoration: BoxDecoration( + color: const Color(0xff08251E), + borderRadius: BorderRadius.circular(8.w), + border: Border.all(color: const Color(0xffB2FBCC), width: 1), + ), + child: TextField( + controller: _passwordController, + autofocus: true, + obscureText: true, + cursorColor: SocialChatTheme.primaryColor, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + inputFormatters: [LengthLimitingTextInputFormatter(24)], + onSubmitted: (_) { + _handleEnter(); + }, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: "Please enter password", + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 14.sp, + height: 16 / 14, + ), + filled: true, + fillColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 12.w), + ), + style: TextStyle( + color: Colors.white, + backgroundColor: Colors.transparent, + fontSize: 14.sp, + fontWeight: FontWeight.w400, + height: 16 / 14, + ), + ), + ); + } + + Widget _buildErrorText() { + return SizedBox( + width: double.infinity, + child: Text( + _errorText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color(0xffff4d4d), + fontSize: 12.sp, + fontWeight: FontWeight.w400, + height: 14 / 12, + ), + ), + ); + } + + Widget _buildButton({ + required String label, + required bool filled, + required VoidCallback? onTap, + }) { + final child = Container( + height: 38.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled ? SocialChatTheme.primaryColor : Colors.transparent, + borderRadius: BorderRadius.circular(19.w), + border: Border.all( + color: filled ? SocialChatTheme.primaryColor : Colors.white, + width: 1.w, + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: filled ? Colors.black : Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + height: 1, + ), + ), + ); + + return Opacity( + opacity: onTap == null ? 0.55 : 1, + child: SCDebounceWidget(onTap: onTap ?? () {}, child: child), + ); + } +} diff --git a/lib/ui_kit/widgets/room/room_play_widget.dart b/lib/ui_kit/widgets/room/room_play_widget.dart index f41879e..9947d30 100644 --- a/lib/ui_kit/widgets/room/room_play_widget.dart +++ b/lib/ui_kit/widgets/room/room_play_widget.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/services/music/room_music_manager.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart'; import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_models.dart'; @@ -35,9 +36,15 @@ class _RoomPlayWidgetState extends State { bottom: (150 - _rightActionDownShift).w, child: const RoomMusicFloatingEntry(), ), - PositionedDirectional( - end: 65.w, - bottom: (150 - _rightActionDownShift).w, + Selector( + selector: (_, manager) => manager.current != null, + builder: (context, hasCurrentMusic, child) { + return PositionedDirectional( + end: (hasCurrentMusic ? 65 : 15).w, + bottom: (150 - _rightActionDownShift).w, + child: child!, + ); + }, child: const RoomRocketFloatingEntry(), ), PositionedDirectional( diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index 8cb75e7..ec5cac4 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -12,6 +12,7 @@ import 'package:yumi/modules/index/main_route.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart'; import 'package:yumi/ui_kit/widgets/room/room_user_card_setting.dart'; +import 'package:yumi/ui_kit/widgets/badge/sc_user_badge_strip.dart'; import 'package:marquee/marquee.dart'; import 'package:provider/provider.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; @@ -49,35 +50,6 @@ class _RoomUserInfoCardState extends State { String roomId = ""; SCUserIdentityRes? userIdentity; - Widget _buildCardAction({ - required String iconAsset, - required String label, - required VoidCallback onTap, - }) { - return SCDebounceWidget( - onTap: onTap, - child: Column( - children: [ - Image.asset(iconAsset, width: 32.w, height: 32.w), - SizedBox(height: 5.w), - Container( - alignment: Alignment.center, - width: 70.w, - child: text( - label, - maxLines: 2, - textAlign: TextAlign.center, - textColor: Color(0xff808080), - fontWeight: FontWeight.w600, - fontSize: 14.sp, - lineHeight: 1.1, - ), - ), - ], - ), - ); - } - @override void initState() { super.initState(); @@ -93,6 +65,9 @@ class _RoomUserInfoCardState extends State { ""; userProvider?.roomUserCard(roomId, widget.userId ?? ""); SCAccountRepository().userIdentity(userId: widget.userId).then((v) { + if (!mounted) { + return; + } userIdentity = v; setState(() {}); }); @@ -106,635 +81,586 @@ class _RoomUserInfoCardState extends State { @override Widget build(BuildContext context) { - return SafeArea( - top: false, - child: Consumer( - builder: (context, ref, child) { - final currentUserId = - AccountStorage().getCurrentUser()?.userProfile?.id; - final isSelf = widget.userId == currentUserId; - final rtcProvider = Provider.of(context, listen: false); - final currentMicIndex = - isSelf && currentUserId != null - ? rtcProvider.userOnMaiInIndex(currentUserId) - : -1; - final canLeaveMic = currentMicIndex > -1; - if (ref.userCardInfo == null) { - return SizedBox( - height: ScreenUtil().screenHeight * 0.7, - child: Center( - child: Container( - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(8), - ), - width: 55.w, - height: 55.w, - child: CupertinoActivityIndicator(color: Colors.white24), - ), - ), - ); - } - return Container( - constraints: BoxConstraints( - maxHeight: ScreenUtil().screenHeight * 0.7, - ), - margin: EdgeInsets.symmetric(horizontal: 15.w), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - alignment: Alignment.center, - children: [ - Container( - margin: EdgeInsets.only( - top: - ref.userCardInfo?.userProfile - ?.getDataCard() - ?.sourceUrl != - null - ? 45.w - : 55.w, - ), - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: - ref.userCardInfo?.userProfile - ?.getDataCard() - ?.sourceUrl != - null - ? [ - Colors.transparent, - Colors.transparent, - ] - : [ - Color(0xff18F2B1), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffFFFFFF), - ], - ), - borderRadius: BorderRadius.all( - Radius.circular(12.w), - ), - ), - child: Stack( - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - SizedBox(height: 25.w), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - SizedBox(width: 20.w), - GestureDetector( - child: head( - url: - ref - .userCardInfo - ?.userProfile - ?.userAvatar ?? - "", - width: 55.w, - border: Border.all( - color: Colors.white, - width: 2, - ), - headdress: - ref.userCardInfo?.userProfile - ?.getHeaddress() - ?.sourceUrl, - headdressCover: - ref.userCardInfo?.userProfile - ?.getHeaddress() - ?.cover, - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == widget.userId}&tageId=${widget.userId}", - ); - }, - ), - SizedBox(width: 3.w), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - msgRoleTag( - ref - .userCardInfo - ?.roomRole ?? - "", - width: 16.w, - height: 16.w, - ), - SizedBox(width: 2.w), - socialchatNickNameText( - maxWidth: 115.w, - ref - .userCardInfo - ?.userProfile - ?.userNickname ?? - "", - fontSize: 14.sp, - textColor: Colors.black, - fontWeight: FontWeight.bold, - type: "", - needScroll: - (ref - .userCardInfo - ?.userProfile - ?.userNickname - ?.characters - .length ?? - 0) > - 10, - ), - ], - ), - SizedBox(height: 2.w), - SCSpecialIdBadge( - idText: - ref - .userCardInfo - ?.userProfile - ?.getID() ?? - "", - showAnimated: - ref - .userCardInfo - ?.userProfile - ?.hasSpecialId() ?? - false, - assetPath: - SCSpecialIdAssets.userId, - animationWidth: 62.w, - animationHeight: 24.w, - showTextBesideAnimated: true, - animatedTextSpacing: 0, - showAnimatedGradientText: - ref - .userCardInfo - ?.userProfile - ?.shouldShowColoredSpecialIdText() ?? - false, - animationTextStyle: TextStyle( - color: Colors.black, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - normalTextStyle: TextStyle( - color: Colors.black, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - animationFit: BoxFit.contain, - ), - ], - ), - ], - ), - Row( - mainAxisAlignment: - MainAxisAlignment.start, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(15.w), - color: Colors.transparent, - ), - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 5.w, - ), - child: Row( - children: [ - SizedBox(width: 20.w), - netImage( - url: - Provider.of< - SCAppGeneralManager - >( - context, - listen: false, - ) - .findCountryByName( - ref - .userCardInfo - ?.userProfile - ?.countryName ?? - "", - ) - ?.nationalFlag ?? - "", - borderRadius: - BorderRadius.all( - Radius.circular(3.w), - ), - width: 19.w, - height: 14.w, - ), - SizedBox(width: 5.w), - Container( - constraints: BoxConstraints( - maxWidth: 110.w, - ), - child: text( - ref - .userCardInfo - ?.userProfile - ?.countryName ?? - "", - textColor: Colors.black, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - SizedBox(width: 5.w), - Container( - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - ref - .userCardInfo - ?.userProfile - ?.userSex == - 0 - ? "sc_images/login/sc_icon_sex_woman_bg.png" - : "sc_images/login/sc_icon_sex_man_bg.png", - ), - ), - ), - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 5.w, - ), - child: Row( - children: [ - xb( - ref - .userCardInfo - ?.userProfile - ?.userSex, - ), - SizedBox(width: 3.w), - text( - "${ref.userCardInfo?.userProfile?.age ?? 0}", - textColor: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ], - ), - ), - ], - ), + final sheetHeight = MediaQuery.of(context).size.height * 0.4; - SizedBox(height: 15.w), - Container( - margin: EdgeInsets.symmetric( - horizontal: 25.w, - ), - child: - isSelf - ? (canLeaveMic - ? Row( - mainAxisAlignment: - MainAxisAlignment - .center, - children: [ - _buildCardAction( - iconAsset: - _leaveMicActionIconAsset, - label: - SCAppLocalizations.of( - context, - )!.leavelTheMic, - onTap: () { - Navigator.of( - context, - ).pop(); - rtcProvider.xiaMai( - currentMicIndex, - ); - }, - ), - ], - ) - : const SizedBox.shrink()) - : Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - _buildCardAction( - iconAsset: - "sc_images/room/sc_icon_send_user_message.png", - label: - SCAppLocalizations.of( - context, - )!.sayHi2, - onTap: () async { - Navigator.of( - context, - ).pop(); - var conversation = - V2TimConversation( - type: - ConversationType - .V2TIM_C2C, - userID: - widget.userId, - conversationID: - '', - ); - var bool = - await Provider.of< - RtmProvider - >( - context, - listen: false, - ).startConversation( - conversation, - ); - if (!bool) return; - var json = jsonEncode( - conversation.toJson(), - ); - SCNavigatorUtils.push( - navigatorKey - .currentState! - .context, - "${SCChatRouter.chat}?conversation=${Uri.encodeComponent(json)}", - ); - }, - ), - SCDebounceWidget( - child: Selector< - SocialChatUserProfileManager, - bool - >( - selector: - ( - context, - provider, - ) => - provider - .userCardInfo - ?.follow ?? - false, - shouldRebuild: - (prev, next) => - prev != next, - builder: ( - context, - follow, - _, - ) { - return Column( - children: [ - Image.asset( - follow - ? "sc_images/room/sc_icon_user_un_follow.png" - : "sc_images/room/sc_icon_user_follow.png", - width: 32.w, - height: 32.w, - ), - SizedBox( - height: 5.w, - ), - Container( - alignment: - Alignment - .center, - width: 70.w, - child: text( - follow - ? SCAppLocalizations.of( - context, - )!.followed - : SCAppLocalizations.of( - context, - )!.follow, - fontSize: - 14.sp, - textColor: Color( - 0xff808080, - ), - fontWeight: - FontWeight - .bold, - ), - ), - ], - ); - }, - ), - onTap: () { - final provider = - Provider.of< - SocialChatUserProfileManager - >( - context, - listen: false, - ); - provider.followUser( - widget.userId ?? "", - ); - }, - ), - _buildCardAction( - iconAsset: - "sc_images/room/sc_icon_at_tag_user.png", - label: - SCAppLocalizations.of( - context, - )!.atTag, - onTap: () async { - if (SCRoomUtils.touristCanMsg( - context, - )) { - if ((ref - .userCardInfo - ?.userProfile - ?.userNickname ?? - "") - .isEmpty) { - return; - } - Navigator.of( - context, - ).pop(); - Navigator.push( - context, - PopRoute( - child: RoomMsgInput( - atTextContent: - "${ref.userCardInfo?.userProfile?.userNickname}", - ), - ), - ); - } - }, - ), - _buildCardAction( - iconAsset: - "sc_images/room/sc_icon_send_user_gift.png", - label: - SCAppLocalizations.of( - context, - )!.send, - onTap: () { - Navigator.of( - context, - ).pop(); - SmartDialog.show( - tag: - "showGiftControl", - alignment: - Alignment - .bottomCenter, - maskColor: - Colors - .transparent, - animationType: - SmartAnimationType - .fade, - clickMaskDismiss: - true, - builder: (_) { - return GiftPage( - toUser: - ref - .userCardInfo - ?.userProfile, - ); - }, - ); - }, - ), - ], - ), - ), - SizedBox( - height: - isSelf - ? (canLeaveMic ? 35.w : 15.w) - : 35.w, - ), - ], - ), - ], - ), - ), - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.id != - widget.userId - ? Positioned( - top: 28.w, - right: 15.w, - child: GestureDetector( - child: Image.asset( - "sc_images/room/sc_icon_user_card_report.png", - width: 24.w, - height: 24.w, - color: Colors.black, - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.report}?type=user&tageId=${widget.userId}", - replace: false, - ); - }, - ), - ) - : Container(), - ref.userCardInfo?.roomRole == - SCRoomRolesType.HOMEOWNER.name - ? Container() - : (AccountStorage() - .getCurrentUser() - ?.userProfile - ?.id != - widget.userId && - Provider.of( - context, - listen: false, - ).isFz() || - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.id != - widget.userId && - Provider.of( - context, - listen: false, - ).isGL()) - ? Positioned( - top: 28.w, - right: 45.w, - child: GestureDetector( - child: Image.asset( - "sc_images/room/sc_icon_room_user_card_setting.png", - width: 24.w, - height: 24.w, - color: Colors.black, - ), - onTap: () { - if (userProvider?.userCardInfo != null) { - Navigator.of(context).pop(); - showBottomInBottomDialog( - context!, - RoomUserCardSetting( - roomId: roomId, - userCardInfo: - userProvider?.userCardInfo - ?.copyWith(), - ), - ); - } - }, - ), - ) - : Container(), - ], - ), + return Consumer( + builder: (context, ref, child) { + final currentUserId = + AccountStorage().getCurrentUser()?.userProfile?.id; + final isSelf = widget.userId == currentUserId; + final rtcProvider = Provider.of(context, listen: false); + final currentMicIndex = + isSelf && currentUserId != null + ? rtcProvider.userOnMaiInIndex(currentUserId) + : -1; + final canLeaveMic = currentMicIndex > -1; + + if (ref.userCardInfo == null) { + return _buildLoadingSheet(sheetHeight); + } + + return _buildProfileSheet( + context: context, + ref: ref, + rtcProvider: rtcProvider, + sheetHeight: sheetHeight, + isSelf: isSelf, + currentMicIndex: currentMicIndex, + canLeaveMic: canLeaveMic, + currentUserId: currentUserId, + ); + }, + ); + } + + Widget _buildLoadingSheet(double sheetHeight) { + return SizedBox( + width: double.infinity, + height: sheetHeight, + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: DecoratedBox( + decoration: _sheetDecoration(), + child: Center( + child: Container( + width: 55.w, + height: 55.w, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8.w), + ), + child: CupertinoActivityIndicator(color: Colors.white24), + ), + ), + ), + ), + ); + } + + Widget _buildProfileSheet({ + required BuildContext context, + required SocialChatUserProfileManager ref, + required RtcProvider rtcProvider, + required double sheetHeight, + required bool isSelf, + required num currentMicIndex, + required bool canLeaveMic, + required String? currentUserId, + }) { + final bottomPadding = MediaQuery.of(context).padding.bottom; + final profile = ref.userCardInfo?.userProfile; + final canShowReport = currentUserId != widget.userId; + final canShowSetting = + canShowReport && + ref.userCardInfo?.roomRole != SCRoomRolesType.HOMEOWNER.name && + (rtcProvider.isFz() || rtcProvider.isGL()); + + return SizedBox( + width: double.infinity, + height: sheetHeight, + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: DecoratedBox( + decoration: _sheetDecoration(), + child: Stack( + children: [ + Positioned.fill( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.fromLTRB( + 18.w, + 16.w, + 18.w, + bottomPadding + 10.w, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildProfileHeader(context, profile, ref), + SizedBox(height: 6.w), + _buildInfoChips(context, profile), + SizedBox(height: 9.w), + _buildBadgeStrip(profile?.id ?? widget.userId), + SizedBox(height: 10.w), + _buildActions( + context: context, + ref: ref, + rtcProvider: rtcProvider, + isSelf: isSelf, + canLeaveMic: canLeaveMic, + currentMicIndex: currentMicIndex, ), ], ), - ], + ), + ), + if (canShowReport) + Positioned( + top: 14.w, + right: 16.w, + child: _buildHeaderIconButton( + iconAsset: "sc_images/room/sc_icon_user_card_report.png", + onTap: () { + final route = + "${SCMainRoute.report}?type=user&tageId=${widget.userId}"; + Navigator.of(context).pop(); + SCNavigatorUtils.push( + navigatorKey.currentState?.context ?? context, + route, + replace: false, + ); + }, + ), + ), + if (canShowSetting) + Positioned( + top: 14.w, + right: 52.w, + child: _buildHeaderIconButton( + iconAsset: + "sc_images/room/sc_icon_room_user_card_setting.png", + onTap: () { + if (userProvider?.userCardInfo == null) { + return; + } + Navigator.of(context).pop(); + showBottomInBottomDialog( + navigatorKey.currentState?.context ?? context, + RoomUserCardSetting( + roomId: roomId, + userCardInfo: userProvider?.userCardInfo?.copyWith(), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + BoxDecoration _sheetDecoration() { + return const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xff18F2B1), + Color(0xffE4FFF6), + Color(0xffF7FFFC), + Color(0xffFFFFFF), + ], + ), + ); + } + + Widget _buildProfileHeader( + BuildContext context, + SocialChatUserProfile? profile, + SocialChatUserProfileManager ref, + ) { + return Column( + children: [ + GestureDetector( + onTap: () => _openProfile(context), + child: head( + url: profile?.userAvatar ?? "", + width: 70.w, + border: Border.all(color: Colors.white, width: 2), + headdress: profile?.getHeaddress()?.sourceUrl, + headdressCover: profile?.getHeaddress()?.cover, + ), + ), + SizedBox(height: 5.w), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + msgRoleTag( + ref.userCardInfo?.roomRole ?? "", + width: 16.w, + height: 16.w, + ), + SizedBox(width: 4.w), + socialchatNickNameText( + maxWidth: 190.w, + profile?.userNickname ?? "", + fontSize: 18.sp, + textColor: Colors.black, + fontWeight: FontWeight.bold, + type: "", + needScroll: (profile?.userNickname?.characters.length ?? 0) > 12, + ), + ], + ), + SizedBox(height: 2.w), + SCSpecialIdBadge( + idText: profile?.getID() ?? "", + showAnimated: profile?.hasSpecialId() ?? false, + assetPath: SCSpecialIdAssets.userId, + animationWidth: 62.w, + animationHeight: 24.w, + showTextBesideAnimated: true, + animatedTextSpacing: 0, + showAnimatedGradientText: + profile?.shouldShowColoredSpecialIdText() ?? false, + animationTextStyle: TextStyle( + color: Colors.black, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + normalTextStyle: TextStyle( + color: Colors.black, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + animationFit: BoxFit.contain, + ), + ], + ); + } + + Widget _buildInfoChips(BuildContext context, SocialChatUserProfile? profile) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildCountryChip(context, profile), + SizedBox(width: 7.w), + _buildSexAgeChip(profile), + ], + ); + } + + Widget _buildCountryChip( + BuildContext context, + SocialChatUserProfile? profile, + ) { + final countryName = profile?.countryName ?? ""; + final flagUrl = + Provider.of( + context, + listen: false, + ).findCountryByName(countryName)?.nationalFlag ?? + ""; + + return Container( + constraints: BoxConstraints(maxWidth: 145.w), + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(16.w), + border: Border.all( + color: const Color(0xff18F2B1).withValues(alpha: 0.18), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + netImage( + url: flagUrl, + borderRadius: BorderRadius.all(Radius.circular(3.w)), + width: 19.w, + height: 14.w, + ), + SizedBox(width: 5.w), + Flexible( + child: text( + countryName, + maxLines: 1, + textColor: Colors.black, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + Widget _buildSexAgeChip(SocialChatUserProfile? profile) { + return Container( + height: 26.w, + padding: EdgeInsets.symmetric(horizontal: 10.w), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + profile?.userSex == 0 + ? "sc_images/login/sc_icon_sex_woman_bg.png" + : "sc_images/login/sc_icon_sex_man_bg.png", + ), + fit: BoxFit.fill, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + xb(profile?.userSex), + SizedBox(width: 3.w), + text( + "${profile?.age ?? 0}", + textColor: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + ], + ), + ); + } + + Widget _buildBadgeStrip(String? userId) { + return SCUserBadgeStrip( + userId: userId, + height: 55.w, + badgeHeight: 23.w, + longBadgeWidth: 58.w, + spacing: 8.w, + maxBadges: 12, + wrap: true, + reserveSpace: true, + ); + } + + Widget _buildActions({ + required BuildContext context, + required SocialChatUserProfileManager ref, + required RtcProvider rtcProvider, + required bool isSelf, + required bool canLeaveMic, + required num currentMicIndex, + }) { + if (isSelf) { + if (!canLeaveMic) { + return SizedBox(height: 55.w); + } + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 92.w, + child: _buildSheetAction( + iconAsset: _leaveMicActionIconAsset, + label: SCAppLocalizations.of(context)!.leavelTheMic, + onTap: () { + Navigator.of(context).pop(); + rtcProvider.xiaMai(currentMicIndex); + }, + ), + ), + ], + ); + } + + return Row( + children: [ + Expanded( + child: _buildSheetAction( + iconAsset: "sc_images/room/sc_icon_send_user_message.png", + label: SCAppLocalizations.of(context)!.sayHi2, + onTap: () => _openChat(context), + ), + ), + Expanded(child: _buildFollowAction(context)), + Expanded( + child: _buildSheetAction( + iconAsset: "sc_images/room/sc_icon_at_tag_user.png", + label: SCAppLocalizations.of(context)!.atTag, + onTap: () => _openAtInput(context, ref), + ), + ), + Expanded( + child: _buildSheetAction( + iconAsset: "sc_images/room/sc_icon_send_user_gift.png", + label: SCAppLocalizations.of(context)!.send, + onTap: () => _openGift(context, ref), + ), + ), + ], + ); + } + + Widget _buildSheetAction({ + required String iconAsset, + required String label, + required VoidCallback onTap, + }) { + return SCDebounceWidget( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42.w, + height: 42.w, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.58), + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xff18F2B1).withValues(alpha: 0.18), ), ), + alignment: Alignment.center, + child: Image.asset(iconAsset, width: 28.w, height: 28.w), + ), + SizedBox(height: 5.w), + SizedBox( + height: 31.w, + child: text( + label, + maxLines: 2, + textAlign: TextAlign.center, + textColor: const Color(0xff666666), + fontWeight: FontWeight.w600, + fontSize: 12.sp, + lineHeight: 1.1, + ), + ), + ], + ), + ); + } + + Widget _buildFollowAction(BuildContext context) { + return SCDebounceWidget( + onTap: () { + final provider = Provider.of( + context, + listen: false, + ); + provider.followUser(widget.userId ?? ""); + }, + child: Selector( + selector: (context, provider) => provider.userCardInfo?.follow ?? false, + shouldRebuild: (prev, next) => prev != next, + builder: (context, follow, _) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42.w, + height: 42.w, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.58), + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xff18F2B1).withValues(alpha: 0.18), + ), + ), + alignment: Alignment.center, + child: Image.asset( + follow + ? "sc_images/room/sc_icon_user_un_follow.png" + : "sc_images/room/sc_icon_user_follow.png", + width: 28.w, + height: 28.w, + ), + ), + SizedBox(height: 5.w), + SizedBox( + height: 31.w, + child: text( + follow + ? SCAppLocalizations.of(context)!.followed + : SCAppLocalizations.of(context)!.follow, + maxLines: 2, + textAlign: TextAlign.center, + textColor: const Color(0xff666666), + fontWeight: FontWeight.w600, + fontSize: 12.sp, + lineHeight: 1.1, + ), + ), + ], ); }, ), ); } + Widget _buildHeaderIconButton({ + required String iconAsset, + required VoidCallback onTap, + }) { + return SCDebounceWidget( + onTap: onTap, + child: Container( + width: 28.w, + height: 28.w, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Image.asset( + iconAsset, + width: 18.w, + height: 18.w, + color: Colors.black, + ), + ), + ); + } + + void _openProfile(BuildContext context) { + final route = + "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == widget.userId}&tageId=${widget.userId}"; + Navigator.of(context).pop(); + SCNavigatorUtils.push(navigatorKey.currentState?.context ?? context, route); + } + + Future _openChat(BuildContext context) async { + final rtmProvider = Provider.of(context, listen: false); + Navigator.of(context).pop(); + var conversation = V2TimConversation( + type: ConversationType.V2TIM_C2C, + userID: widget.userId, + conversationID: '', + ); + final didStart = await rtmProvider.startConversation(conversation); + if (!didStart || !mounted) return; + var json = jsonEncode(conversation.toJson()); + SCNavigatorUtils.push( + navigatorKey.currentState!.context, + "${SCChatRouter.chat}?conversation=${Uri.encodeComponent(json)}", + ); + } + + Future _openAtInput( + BuildContext context, + SocialChatUserProfileManager ref, + ) async { + if (!SCRoomUtils.touristCanMsg(context)) { + return; + } + final nickname = ref.userCardInfo?.userProfile?.userNickname ?? ""; + if (nickname.isEmpty) { + return; + } + Navigator.of(context).pop(); + Navigator.push( + navigatorKey.currentState?.context ?? context, + PopRoute( + child: RoomMsgInput( + atTextContent: "$nickname", + ), + ), + ); + } + + void _openGift(BuildContext context, SocialChatUserProfileManager ref) { + Navigator.of(context).pop(); + SmartDialog.show( + tag: "showGiftControl", + alignment: Alignment.bottomCenter, + maskColor: Colors.transparent, + animationType: SmartAnimationType.fade, + clickMaskDismiss: true, + builder: (_) { + return GiftPage(toUser: ref.userCardInfo?.userProfile); + }, + ); + } + String roleName(String role) { if (role == SCRoomRolesType.HOMEOWNER.name) { return SCAppLocalizations.of(context)!.owner; diff --git a/pubspec.lock b/pubspec.lock index 97547cc..c1e1601 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -635,6 +635,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" flutter_svga: dependency: "direct main" description: @@ -1137,6 +1145,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.2.1" + pag: + dependency: "direct main" + description: + name: pag + sha256: "8ba3b9c18309b0ef71ad53d20c67ed13893ad3cc49fdb7cc48a8d4e78bfe6db7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.7" path: dependency: "direct main" description: @@ -1645,6 +1661,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "4d35a36400983c3457c289d4d553b5308f506ea84f7e51c7a564651b5525209a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "98e7e94de127b46a86ef46197fff84ff99f3d3b80a708390d717ad731efef598" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d57aa39..2c4a4ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.3.1+131 +version: 1.4.0+140 environment: @@ -133,6 +133,8 @@ dependencies: readmore: ^3.0.0 #外链跳转 app_links: ^6.4.1 + flutter_svg: ^2.3.0 + pag: ^1.0.7 dev_dependencies: flutter_launcher_icons: ^0.14.4 diff --git a/sc_images/room/rocket/rocket_floating_banner_bg.png b/sc_images/room/rocket/rocket_floating_banner_bg.png new file mode 100644 index 0000000..71ae137 Binary files /dev/null and b/sc_images/room/rocket/rocket_floating_banner_bg.png differ