1.4
This commit is contained in:
parent
bd6d35ee1b
commit
77d1fd13b1
@ -723,5 +723,8 @@
|
||||
"duration2": "المدة:{1}",
|
||||
"roomRocketNoteTitle": "ملاحظة",
|
||||
"roomRocketNoteSectionTitle": "في كل مرة يتم فيها شراء مستوى VIP",
|
||||
"roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية."
|
||||
"roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية.",
|
||||
"roomRocketRewardEmptyText": "لم تحصل على مكافآت. حظًا أوفر في المرة القادمة.",
|
||||
"roomRocketRecordEmptyText": "لا توجد سجلات مكافآت",
|
||||
"launchRocket": "إطلاق الصاروخ"
|
||||
}
|
||||
|
||||
@ -723,5 +723,8 @@
|
||||
"duration2": "সময়:{1}",
|
||||
"roomRocketNoteTitle": "নোট",
|
||||
"roomRocketNoteSectionTitle": "প্রতিবার VIP লেভেল কেনার সময়",
|
||||
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।"
|
||||
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।",
|
||||
"roomRocketRewardEmptyText": "কোনো পুরস্কার পাওয়া যায়নি। পরের বার শুভকামনা।",
|
||||
"roomRocketRecordEmptyText": "কোনো পুরস্কারের রেকর্ড নেই",
|
||||
"launchRocket": "রকেট চালু করুন"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = "";
|
||||
|
||||
@ -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');
|
||||
|
||||
66
lib/debug/room_rocket_floating_preview.dart
Normal file
66
lib/debug/room_rocket_floating_preview.dart
Normal file
@ -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: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -60,10 +60,12 @@ class GiftPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
static const String _backpackGiftTab = SCAppGeneralManager.backpackGiftTab;
|
||||
static const Duration _comboSendBatchWindow = Duration(milliseconds: 200);
|
||||
|
||||
static const List<String> _preferredGiftTabOrder = <String>[
|
||||
"ALL",
|
||||
_backpackGiftTab,
|
||||
"ACTIVITY",
|
||||
"LUCKY_GIFT",
|
||||
"CP",
|
||||
@ -218,7 +220,12 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
listen: false,
|
||||
).giftList(includeCustomized: false);
|
||||
Provider.of<SCAppGeneralManager>(context, listen: false).giftActivityList();
|
||||
// Provider.of<GeneralProvider>(context, listen: false).giftBackpack();
|
||||
unawaited(
|
||||
Provider.of<SCAppGeneralManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).giftBackpack(forceRefresh: true),
|
||||
);
|
||||
Provider.of<SocialChatUserProfileManager>(context, listen: false).balance();
|
||||
rtcProvider?.roomWheatMap.forEach((k, v) {
|
||||
if (v.user != null) {
|
||||
@ -262,10 +269,23 @@ class _GiftPageState extends State<GiftPage> 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 = <String>[];
|
||||
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<GiftPage> with TickerProviderStateMixin {
|
||||
|
||||
void _ensureCurrentTabSelection(SCAppGeneralManager ref, String tabType) {
|
||||
final gifts = ref.giftByTab[tabType] ?? const <SocialChatGiftRes>[];
|
||||
if (_matchesCurrentGift(gifts)) {
|
||||
if (_matchesCurrentGift(tabType, gifts)) {
|
||||
return;
|
||||
}
|
||||
final gift = _resolveSelectedGift(tabType, gifts);
|
||||
@ -347,6 +367,13 @@ class _GiftPageState extends State<GiftPage> 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<GiftPage> with TickerProviderStateMixin {
|
||||
return gifts.first;
|
||||
}
|
||||
|
||||
bool _matchesCurrentGift(List<SocialChatGiftRes> gifts) {
|
||||
bool _matchesCurrentGift(String tabType, List<SocialChatGiftRes> 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<GiftPage> 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<GiftPage> 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<GiftPage> with TickerProviderStateMixin {
|
||||
|
||||
if (error is DioException) {
|
||||
final responseData = error.response?.data;
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
if (responseData is Map) {
|
||||
final responseMessage = sanitize(
|
||||
responseData['errorMsg']?.toString() ?? '',
|
||||
);
|
||||
@ -488,6 +531,47 @@ class _GiftPageState extends State<GiftPage> 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 = <String>[];
|
||||
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<SCAppGeneralManager>(
|
||||
@ -1108,7 +1192,51 @@ class _GiftPageState extends State<GiftPage> 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<SCAppGeneralManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).giftBackpack(forceRefresh: true),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLuckyGiftRequest && !_hasValidLuckyGiftStandardId(selectedGift)) {
|
||||
const configError =
|
||||
@ -1134,6 +1262,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
roomId: roomId,
|
||||
roomAccount: roomAccount,
|
||||
isLuckyGiftRequest: isLuckyGiftRequest,
|
||||
isBackpackGiftRequest: isBackpackGiftRequest,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1144,19 +1273,23 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
return;
|
||||
}
|
||||
|
||||
RoomGiftComboSendController().show(
|
||||
request: RoomGiftComboSendRequest(
|
||||
acceptUserIds: List<String>.from(request.acceptUserIds),
|
||||
acceptUsers: List<MicRes>.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<String>.from(request.acceptUserIds),
|
||||
acceptUsers: List<MicRes>.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<GiftPage> 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<GiftPage> 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<GiftPage> 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<GiftPage> with TickerProviderStateMixin {
|
||||
'details={$errorDetails} '
|
||||
'elapsedMs=${stopwatch.elapsedMilliseconds}',
|
||||
);
|
||||
if (request.isBackpackGiftRequest) {
|
||||
unawaited(_refreshGiftBackpack());
|
||||
}
|
||||
SCTts.show(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<MicRes>> _giveBackpackGiftToRecipients(
|
||||
SCChatRoomRepository repository,
|
||||
_GiftSendRequest request,
|
||||
) async {
|
||||
final sentUsers = <MicRes>[];
|
||||
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 <MicRes>[]) {
|
||||
if ((mic.user?.id ?? "").trim() == normalizedUserId) {
|
||||
return mic.micIndex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _refreshGiftBackpack() async {
|
||||
final context = navigatorKey.currentState?.context;
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
await Provider.of<SCAppGeneralManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).giftBackpack(forceRefresh: true);
|
||||
}
|
||||
|
||||
void sendGiftMsg(
|
||||
List<MicRes> acceptUsers, {
|
||||
required SocialChatGiftRes gift,
|
||||
@ -1628,6 +1867,7 @@ class _GiftPageState extends State<GiftPage> 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<GiftPage> with TickerProviderStateMixin {
|
||||
final sortedAcceptUserIds = List<String>.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<String> 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<String>.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ class _GiftTabPageState extends State<GiftTabPage>
|
||||
_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<GiftTabPage>
|
||||
),
|
||||
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<GiftTabPage>
|
||||
);
|
||||
}
|
||||
|
||||
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 = <Widget>[];
|
||||
|
||||
@ -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<FollowRoomRes> previous, List<FollowRoomRes> next) {
|
||||
|
||||
@ -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<FollowRoomRes> previous, List<FollowRoomRes> next) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -43,7 +43,7 @@ class _RoomOnlinePageState
|
||||
if (dialogContext == null) {
|
||||
return;
|
||||
}
|
||||
showBottomInCenterDialog(dialogContext, RoomUserInfoCard(userId: userId));
|
||||
showBottomInBottomDialog(dialogContext, RoomUserInfoCard(userId: userId));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -98,7 +98,7 @@ class _RoomGiftRankTabPageState
|
||||
// headdress: userInfo.userProfile?.getHeaddress()?.sourceUrl,
|
||||
),
|
||||
onTap: () {
|
||||
showBottomInCenterDialog(
|
||||
showBottomInBottomDialog(
|
||||
navigatorKey.currentState!.context,
|
||||
RoomUserInfoCard(userId: userProfile?.id),
|
||||
);
|
||||
|
||||
@ -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<RoomRocketRankPage> {
|
||||
static const String _effectSuppressReason = 'room_rocket_rank';
|
||||
static const int _levelCount = 6;
|
||||
|
||||
late int _selectedLevel;
|
||||
final Map<int, List<RoomRocketRankEntry>> _loadedRanks = {};
|
||||
final Map<int, int> _loadedRoundNos = {};
|
||||
final Map<int, RoomRocketRankEntry> _loadedCurrentUsers = {};
|
||||
final Set<int> _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<RoomRocketRankPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_releaseRoomEffects();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
@ -110,6 +128,9 @@ class _RoomRocketRankPageState extends State<RoomRocketRankPage> {
|
||||
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<RoomRocketRankPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _suppressRoomEffects() {
|
||||
if (_roomEffectsSuppressed) {
|
||||
return;
|
||||
}
|
||||
_roomEffectsSuppressed = true;
|
||||
try {
|
||||
final rtcProvider = Provider.of<RtcProvider>(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<List<RoomRocketRankEntry>> get _normalizedRanks {
|
||||
final providedRanks = widget.levelRanks;
|
||||
if (providedRanks == null) {
|
||||
@ -178,7 +254,9 @@ class _RoomRocketRankPageState extends State<RoomRocketRankPage> {
|
||||
_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<RoomRocketRankPage> {
|
||||
});
|
||||
})
|
||||
.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<List<RoomRocketRankEntry>> ranksByLevel;
|
||||
final RoomRocketRankEntry currentUser;
|
||||
final ValueChanged<int> 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';
|
||||
}
|
||||
|
||||
@ -209,6 +209,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
void _ensureRoomVisualEffectsEnabled() {
|
||||
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
|
||||
if (rtcProvider.currenRoom == null ||
|
||||
!_roomRouteVisible ||
|
||||
rtcProvider.roomVisualEffectsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -535,17 +535,17 @@ class _SearchRoomListState extends State<SearchRoomList> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
|
||||
@ -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<PersonDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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 <WearBadge>[],
|
||||
height: 26.w,
|
||||
badgeHeight: 24.w,
|
||||
longBadgeWidth: 58.w,
|
||||
spacing: 6.w,
|
||||
maxBadges: 8,
|
||||
reserveSpace: true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -612,7 +620,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
|
||||
SizedBox(height: 5.w),
|
||||
_buildHeaderMeta(ref),
|
||||
SizedBox(height: 4.w),
|
||||
_buildHeaderBadgePlaceholder(),
|
||||
_buildHeaderBadgeStrip(ref),
|
||||
SizedBox(height: 6.w),
|
||||
_buildHeaderIntroduction(ref),
|
||||
SizedBox(height: 12.w),
|
||||
|
||||
@ -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<void> _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<SocialChatUserProfileManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).userIdentity;
|
||||
if (userIdentity?.superAdmin == true ||
|
||||
userIdentity?.admin == true ||
|
||||
userIdentity?.yumiManager == true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<JoinRoomRes?> _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<void> 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),
|
||||
);
|
||||
|
||||
@ -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<String> _sentRoomRocketRewardMessageKeys = <String>{};
|
||||
|
||||
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<void> _sendRoomRocketRewardPublicMessages(
|
||||
String roomId,
|
||||
Map<String, dynamic> 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<void> _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<List<SCRoomRocketRewardRecordRes>> _loadRocketRewardPublicRecords(
|
||||
String roomId,
|
||||
String launchNo,
|
||||
) async {
|
||||
if (roomId.trim().isEmpty || launchNo.trim().isEmpty) {
|
||||
return const <SCRoomRocketRewardRecordRes>[];
|
||||
}
|
||||
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 <SCRoomRocketRewardRecordRes>[];
|
||||
}
|
||||
}
|
||||
|
||||
List<SCRoomRocketRewardRecordRes> _rocketRewardRecordsFromPayload(
|
||||
Map<String, dynamic> 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 <SCRoomRocketRewardRecordRes>[];
|
||||
}
|
||||
|
||||
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> _rocketRewardUserPayload(
|
||||
Map<String, dynamic> 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 <String, dynamic>{'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<void> _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);
|
||||
|
||||
@ -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<CountryMode> countryModeList = [];
|
||||
@ -32,9 +34,12 @@ class SCAppGeneralManager extends ChangeNotifier {
|
||||
final Set<String> _warmingGiftCoverUrls = <String>{};
|
||||
bool _isFetchingGiftList = false;
|
||||
bool _hasGiftListLoaded = false;
|
||||
bool _isFetchingGiftBackpack = false;
|
||||
bool _hasGiftBackpackLoaded = false;
|
||||
|
||||
Map<String, List<SocialChatGiftRes>> giftByTab = {};
|
||||
Map<String, List<SocialChatGiftRes>> activityGiftByTab = {};
|
||||
List<SocialChatGiftBackpackRes> 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<void> fetchCountryList() async {
|
||||
if (_isFetchingCountryList) {
|
||||
@ -203,6 +215,7 @@ class SCAppGeneralManager extends ChangeNotifier {
|
||||
} else {
|
||||
giftByTab.remove("CUSTOMIZED");
|
||||
}
|
||||
_mergeBackpackGiftTab();
|
||||
}
|
||||
|
||||
Future<void> warmGiftPageCovers(
|
||||
@ -270,12 +283,75 @@ class SCAppGeneralManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
///礼物背包
|
||||
void giftBackpack() async {
|
||||
try {
|
||||
await SCChatRoomRepository().giftBackpack();
|
||||
} catch (_) {
|
||||
Future<void> 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 = <SocialChatGiftRes>[];
|
||||
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获取礼物
|
||||
|
||||
@ -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<String, dynamic> 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;
|
||||
}
|
||||
|
||||
@ -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<String, dynamic> toJson() {
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
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}) {
|
||||
|
||||
@ -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<String, dynamic> 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<SCRoomRocketRewardRecordRes> records;
|
||||
final int cursor;
|
||||
final int limit;
|
||||
final String emptyReason;
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<SCRoomRocketRewardRecordRes> listFromJson(dynamic value) {
|
||||
if (value is! Iterable) {
|
||||
return const <SCRoomRocketRewardRecordRes>[];
|
||||
@ -371,6 +653,62 @@ String _stringValue(dynamic value) {
|
||||
return value.toString().trim();
|
||||
}
|
||||
|
||||
String _firstAssetString(Iterable<dynamic> 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;
|
||||
|
||||
@ -46,6 +46,10 @@ class SCRoomRocketStatusRes {
|
||||
List<SCRoomRocketLevelRes>? levels,
|
||||
SCRoomRocketRewardPreviewRes? rewardPreview,
|
||||
List<SCRoomRocketKingUserRes>? 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<SCRoomRocketLevelRes>? _levels;
|
||||
SCRoomRocketRewardPreviewRes? _rewardPreview;
|
||||
List<SCRoomRocketKingUserRes>? _rocketKings;
|
||||
String? _timezone;
|
||||
int? _resetRemainingSeconds;
|
||||
Object? _resetTime;
|
||||
Object? _serverTime;
|
||||
|
||||
String? get roomId => _roomId;
|
||||
|
||||
@ -182,6 +236,14 @@ class SCRoomRocketStatusRes {
|
||||
|
||||
List<SCRoomRocketKingUserRes>? get rocketKings => _rocketKings;
|
||||
|
||||
String? get timezone => _timezone;
|
||||
|
||||
int? get resetRemainingSeconds => _resetRemainingSeconds;
|
||||
|
||||
Object? get resetTime => _resetTime;
|
||||
|
||||
Object? get serverTime => _serverTime;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
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<Object?> values) {
|
||||
for (final value in values) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (value is String && value.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SCRoomRocketLevelRes>? _levelList(dynamic value) {
|
||||
if (value is! Iterable) {
|
||||
return null;
|
||||
|
||||
76
lib/shared/business_logic/models/res/sc_user_badge_res.dart
Normal file
76
lib/shared/business_logic/models/res/sc_user_badge_res.dart
Normal file
@ -0,0 +1,76 @@
|
||||
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
|
||||
|
||||
class SCUserBadgeRes {
|
||||
SCUserBadgeRes({
|
||||
this.userId,
|
||||
List<WearBadge>? badges,
|
||||
List<WearBadge>? longBadges,
|
||||
List<WearBadge>? shortBadges,
|
||||
}) : badges = badges ?? const <WearBadge>[],
|
||||
longBadges = longBadges ?? const <WearBadge>[],
|
||||
shortBadges = shortBadges ?? const <WearBadge>[];
|
||||
|
||||
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<WearBadge> badges;
|
||||
final List<WearBadge> longBadges;
|
||||
final List<WearBadge> shortBadges;
|
||||
|
||||
List<WearBadge> get displayBadges {
|
||||
if (longBadges.isNotEmpty || shortBadges.isNotEmpty) {
|
||||
return <WearBadge>[...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<WearBadge> _badgeList(dynamic value) {
|
||||
if (value is! List) {
|
||||
return const <WearBadge>[];
|
||||
}
|
||||
return value
|
||||
.whereType<Map>()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -190,6 +190,17 @@ abstract class SocialChatRoomRepository {
|
||||
///礼物背包
|
||||
Future<List<SocialChatGiftBackpackRes>> giftBackpack();
|
||||
|
||||
///赠送背包礼物
|
||||
Future<Object?> giveBackpackGift({
|
||||
required String id,
|
||||
required String acceptUserId,
|
||||
required String giftId,
|
||||
required num quantity,
|
||||
String? roomId,
|
||||
num? seatIndex,
|
||||
String? songId,
|
||||
});
|
||||
|
||||
///查询房间火箭状态
|
||||
Future<SCRoomRocketStatusRes> rocketStatus(String roomId);
|
||||
|
||||
@ -203,7 +214,11 @@ abstract class SocialChatRoomRepository {
|
||||
});
|
||||
|
||||
///查询中奖弹窗
|
||||
Future<SCRoomRocketRewardPopupRes> roomRocketRewardPopups(String roomId);
|
||||
Future<SCRoomRocketRewardPopupRes> roomRocketRewardPopups(
|
||||
String roomId, {
|
||||
int cursor = 1,
|
||||
int limit = 10,
|
||||
});
|
||||
|
||||
///确认中奖弹窗已读
|
||||
Future<bool> ackRoomRocketRewardPopups(List<String> rewardRecordIds);
|
||||
@ -212,7 +227,7 @@ abstract class SocialChatRoomRepository {
|
||||
Future<SCRoomRocketRewardRecordsRes> roomRocketRewardRecords(
|
||||
String roomId, {
|
||||
int cursor = 1,
|
||||
int limit = 20,
|
||||
int limit = 10,
|
||||
});
|
||||
|
||||
///查询火箭规则说明
|
||||
|
||||
@ -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<SocialChatRoomRes> createRoom();
|
||||
|
||||
///加入房间
|
||||
Future<JoinRoomRes> entryRoom(String roomId, {String? pwd});
|
||||
Future<JoinRoomRes> 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<List<WearBadge>> badgeOwnList(String type);
|
||||
|
||||
///查询当前用户正在展示的徽章
|
||||
Future<SCUserBadgeRes> currentUserBadges();
|
||||
|
||||
///查询其他用户正在展示的徽章
|
||||
Future<SCUserBadgeRes> otherUserBadges(String userId);
|
||||
|
||||
///切换佩戴徽章
|
||||
Future<bool> badgeToggle(String badgeId);
|
||||
|
||||
|
||||
@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<dynamic> 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;
|
||||
|
||||
@ -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<int> _stackedFloatingOrder = [5, 1, 4, 3];
|
||||
static const double _stackedFloatingBaseTop = 70;
|
||||
static const double _stackedFloatingGap = 8;
|
||||
|
||||
final SCPriorityQueue<SCFloatingMessage> _messageQueue = SCPriorityQueue(
|
||||
(a, b) => b.priority.compareTo(a.priority),
|
||||
@ -34,6 +37,8 @@ class OverlayManager {
|
||||
final List<SCFloatingMessage> _deferredRegionRedPacketMessages = [];
|
||||
final Map<String, DateTime> _recentCompensationKeys = {};
|
||||
final Map<String, DateTime> _recentFloatingKeys = {};
|
||||
final Map<int, OverlayEntry> _stackedOverlayEntries = {};
|
||||
final Map<int, SCFloatingMessage> _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>[];
|
||||
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<int>.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<RealTimeCommunicationManager>(
|
||||
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 = <int>[];
|
||||
_stackedMessages.forEach((slotKey, message) {
|
||||
if (test(message)) {
|
||||
matchedSlotKeys.add(slotKey);
|
||||
}
|
||||
});
|
||||
if (matchedSlotKeys.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final slotKey in matchedSlotKeys) {
|
||||
_removeStackedMessage(slotKey);
|
||||
}
|
||||
_markStackedEntriesNeedsBuild();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <SCRoomRocketLevelRes>[];
|
||||
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 <String, dynamic>{};
|
||||
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<String, dynamic> _debugMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.map((key, item) => MapEntry(key.toString(), item));
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
static const List<String> _debugIconKeys = [
|
||||
'rocket_icon_url',
|
||||
'rocketIconUrl',
|
||||
'rocketIcon',
|
||||
'iconUrl',
|
||||
'icon',
|
||||
];
|
||||
|
||||
static const List<String> _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<String> _debugAnimationKeys = [
|
||||
'rocket_animation_url',
|
||||
'rocketAnimationUrl',
|
||||
'rocketAnimation',
|
||||
'rocketSvgaUrl',
|
||||
'animationUrl',
|
||||
'animation',
|
||||
'svgaUrl',
|
||||
'rocketLaunchSvgaUrl',
|
||||
'launchAnimationUrl',
|
||||
'launchAnimation',
|
||||
'launchSvgaUrl',
|
||||
'sourceUrl',
|
||||
];
|
||||
|
||||
String _debugAssetValue(Map<String, dynamic> data, List<String> 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<bool> roomLocked(String roomId, String password) async {
|
||||
final result = await http.put<bool>(
|
||||
await http.put<Object?>(
|
||||
"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<bool> roomUnlocked(String roomId) async {
|
||||
final result = await http.post<bool>(
|
||||
await http.post<Object?>(
|
||||
"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<Object?> giveBackpackGift({
|
||||
required String id,
|
||||
required String acceptUserId,
|
||||
required String giftId,
|
||||
required num quantity,
|
||||
String? roomId,
|
||||
num? seatIndex,
|
||||
String? songId,
|
||||
}) async {
|
||||
final params = <String, dynamic>{
|
||||
"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<Object?>(
|
||||
"/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<SCRoomRocketStatusRes> 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<SCRoomRocketRewardPopupRes> 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<SCRoomRocketRewardPopupRes>(
|
||||
@ -875,7 +1109,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
Future<SCRoomRocketRewardRecordsRes> roomRocketRewardRecords(
|
||||
String roomId, {
|
||||
int cursor = 1,
|
||||
int limit = 20,
|
||||
int limit = 10,
|
||||
}) async {
|
||||
final queryParams = {
|
||||
if (roomId.trim().isNotEmpty) "roomId": roomId,
|
||||
|
||||
@ -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<SCUserBadgeRes> currentUserBadges() async {
|
||||
const path = "/app/user/badge/current";
|
||||
final result = await http.get<SCUserBadgeRes>(
|
||||
path,
|
||||
extra: const {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCUserBadgeRes.fromJson(json),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
///app/user/badge/other
|
||||
@override
|
||||
Future<SCUserBadgeRes> otherUserBadges(String userId) async {
|
||||
final params = <String, dynamic>{"userId": userId};
|
||||
const path = "/app/user/badge/other";
|
||||
final result = await http.get<SCUserBadgeRes>(
|
||||
path,
|
||||
queryParams: params,
|
||||
extra: const {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) => SCUserBadgeRes.fromJson(json),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
///user/badge/toggle
|
||||
@override
|
||||
Future<bool> badgeToggle(String badgeId) async {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -23,6 +23,24 @@ typedef SCRoomUtils = SCChatRoomHelper;
|
||||
class SCChatRoomHelper {
|
||||
static Map<String, SocialChatUserProfile> roomUsersMap = {};
|
||||
|
||||
static bool isCurrentRoom(String roomId, BuildContext context) {
|
||||
final normalizedRoomId = roomId.trim();
|
||||
if (normalizedRoomId.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final currentRoomId =
|
||||
Provider.of<RealTimeCommunicationManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).currenRoom?.roomProfile?.roomProfile?.id?.trim() ??
|
||||
'';
|
||||
return currentRoomId.isNotEmpty && currentRoomId == normalizedRoomId;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void goRoom(
|
||||
String roomId,
|
||||
BuildContext context, {
|
||||
|
||||
183
lib/ui_kit/widgets/badge/sc_user_badge_strip.dart
Normal file
183
lib/ui_kit/widgets/badge/sc_user_badge_strip.dart
Normal file
@ -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 <WearBadge>[],
|
||||
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<WearBadge> 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<SCUserBadgeStrip> createState() => _SCUserBadgeStripState();
|
||||
}
|
||||
|
||||
class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
||||
static final Map<String, Future<SCUserBadgeRes>> _badgeFutures = {};
|
||||
|
||||
Future<SCUserBadgeRes>? _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<SCUserBadgeRes>? _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<SCUserBadgeRes>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
final remoteBadges =
|
||||
snapshot.data?.displayBadges ?? const <WearBadge>[];
|
||||
final badges = remoteBadges.isNotEmpty ? remoteBadges : _fallbackBadges;
|
||||
return _buildBadges(badges, stripHeight);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<WearBadge> get _fallbackBadges {
|
||||
return widget.fallbackBadges
|
||||
.where((badge) => _badgeUrl(badge).isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget _buildBadges(List<WearBadge> 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<Widget> _withSpacing(List<Widget> children, double spacing) {
|
||||
if (children.length < 2) {
|
||||
return children;
|
||||
}
|
||||
final spaced = <Widget>[];
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
if (i > 0) {
|
||||
spaced.add(SizedBox(width: spacing));
|
||||
}
|
||||
spaced.add(children[i]);
|
||||
}
|
||||
return spaced;
|
||||
}
|
||||
}
|
||||
@ -247,7 +247,7 @@ class EmptyMaiSelect extends StatelessWidget {
|
||||
context,
|
||||
() {
|
||||
Navigator.of(context).pop();
|
||||
showBottomInCenterDialog(
|
||||
showBottomInBottomDialog(
|
||||
context,
|
||||
RoomUserInfoCard(userId: clickUser?.id),
|
||||
);
|
||||
|
||||
@ -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<FloatingRoomRocketScreenWidget> 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<String?> 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';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<int>? onLevelSelected;
|
||||
final List<RoomRocketRewardItem>? rewards;
|
||||
final RoomRocketRewardGroups? rewardGroups;
|
||||
final RoomRocketRewardTab selectedRewardTab;
|
||||
final ValueChanged<RoomRocketRewardTab>? onRewardTabSelected;
|
||||
final List<RoomRocketCrewMember>? 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<RoomRocketLevelItem>.generate(
|
||||
6,
|
||||
(index) => RoomRocketLevelItem(label: 'Lv.${index + 1}'),
|
||||
(index) =>
|
||||
RoomRocketLevelItem(label: 'Lv.${index + 1}', level: index + 1),
|
||||
growable: false,
|
||||
);
|
||||
}
|
||||
|
||||
static List<RoomRocketRewardItem> _normalizeRewards(
|
||||
List<RoomRocketRewardItem>? rewards,
|
||||
) {
|
||||
List<RoomRocketRewardItem>? rewards, {
|
||||
required bool useFallback,
|
||||
}) {
|
||||
final source =
|
||||
rewards?.where((item) => item.amount.trim().isNotEmpty).toList() ??
|
||||
const <RoomRocketRewardItem>[];
|
||||
if (source.isNotEmpty) {
|
||||
return source;
|
||||
}
|
||||
if (!useFallback) {
|
||||
return const <RoomRocketRewardItem>[];
|
||||
}
|
||||
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 <RoomRocketRewardItem>[],
|
||||
this.setOff = const <RoomRocketRewardItem>[],
|
||||
this.inRoom = const <RoomRocketRewardItem>[],
|
||||
});
|
||||
|
||||
final List<RoomRocketRewardItem> top1;
|
||||
final List<RoomRocketRewardItem> setOff;
|
||||
final List<RoomRocketRewardItem> inRoom;
|
||||
|
||||
List<RoomRocketRewardItem> 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<RoomRocketLevelItem> levels;
|
||||
final int selectedLevel;
|
||||
final RoomRocketLevelItem selectedLevelItem;
|
||||
final ValueChanged<int>? onLevelSelected;
|
||||
final List<RoomRocketRewardItem> rewards;
|
||||
final RoomRocketRewardTab selectedRewardTab;
|
||||
final ValueChanged<RoomRocketRewardTab>? onRewardTabSelected;
|
||||
final List<RoomRocketCrewMember> 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('<svg') ||
|
||||
(trimmed.startsWith('<?xml') && trimmed.contains('<svg'));
|
||||
}
|
||||
|
||||
static String _sourceKind(String source) {
|
||||
if (_isInlineSvg(source)) {
|
||||
return 'inline-svg';
|
||||
}
|
||||
if (SCNetworkSvgaWidget.isSvga(source)) {
|
||||
return 'svga';
|
||||
}
|
||||
if (_isSvg(source)) {
|
||||
return 'svg-url';
|
||||
}
|
||||
return 'url';
|
||||
}
|
||||
|
||||
static String _shortSource(String value) {
|
||||
final text = value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
if (text.length <= 140) {
|
||||
return text;
|
||||
}
|
||||
return '${text.substring(0, 137)}...';
|
||||
}
|
||||
|
||||
void _debugImage(String message) {
|
||||
if (!kDebugMode || debugName == null) {
|
||||
return;
|
||||
}
|
||||
debugPrint('[RoomRocketImage] $debugName $message');
|
||||
}
|
||||
|
||||
Widget _imageFallback() {
|
||||
if (asset != null && asset!.trim().isNotEmpty && !_isSvg(asset!)) {
|
||||
return Image.asset(asset!, fit: fit, gaplessPlayback: true);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
@ -817,6 +1075,7 @@ class _RewardTab extends StatelessWidget {
|
||||
required this.top,
|
||||
required this.label,
|
||||
this.active = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final double scale;
|
||||
@ -824,6 +1083,7 @@ class _RewardTab extends StatelessWidget {
|
||||
final double top;
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -842,32 +1102,36 @@ class _RewardTab extends StatelessWidget {
|
||||
top: top * scale,
|
||||
width: width * scale,
|
||||
height: 14 * scale,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_TabSlash(color: color, scale: scale),
|
||||
SizedBox(width: 3 * scale),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
textScaler: TextScaler.noScaling,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.visible,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12 * scale,
|
||||
height: 1,
|
||||
decoration: TextDecoration.none,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_TabSlash(color: color, scale: scale),
|
||||
SizedBox(width: 3 * scale),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
textScaler: TextScaler.noScaling,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.visible,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12 * scale,
|
||||
height: 1,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 3 * scale),
|
||||
Transform.rotate(
|
||||
angle: math.pi,
|
||||
child: _TabSlash(color: color, scale: scale),
|
||||
),
|
||||
],
|
||||
SizedBox(width: 3 * scale),
|
||||
Transform.rotate(
|
||||
angle: math.pi,
|
||||
child: _TabSlash(color: color, scale: scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1110,6 +1374,244 @@ class _RocketText extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ResetCountdownText extends StatefulWidget {
|
||||
const _ResetCountdownText({required this.status, required this.scale});
|
||||
|
||||
final SCRoomRocketStatusRes? status;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
State<_ResetCountdownText> 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<String> _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._();
|
||||
|
||||
|
||||
@ -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<RtcProvider, SCRoomRocketStatusRes?>(
|
||||
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 <SCRoomRocketLevelRes>[];
|
||||
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<RtcProvider>(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('<svg') ||
|
||||
(trimmed.startsWith('<?xml') && trimmed.contains('<svg'));
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
if (!loading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return const Center(child: SCRotatingDotsLoading());
|
||||
}
|
||||
}
|
||||
|
||||
const int _rocketPageSize = 10;
|
||||
|
||||
class _RoomRocketDialogLoader extends StatefulWidget {
|
||||
const _RoomRocketDialogLoader({
|
||||
required this.roomId,
|
||||
@ -125,11 +305,15 @@ class _RoomRocketDialogLoader extends StatefulWidget {
|
||||
class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
|
||||
SCRoomRocketStatusRes? _status;
|
||||
List<RoomRocketNoteSection>? _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<RtcProvider>(
|
||||
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 <SCRoomRocketLevelRes>[];
|
||||
_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<RoomRocketRewardItem>? _rewards;
|
||||
List<RoomRocketRewardWinner>? _winners;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _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<SCRoomRocketRewardRecordRes> _rawRecords = [];
|
||||
List<RoomRocketRecordItem>? _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<void> _load() async {
|
||||
Future<void> _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)}...';
|
||||
}
|
||||
|
||||
@ -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<RoomRocketPagEffectOverlay> createState() =>
|
||||
_RoomRocketPagEffectOverlayState();
|
||||
}
|
||||
|
||||
class _RoomRocketPagEffectOverlayState
|
||||
extends State<RoomRocketPagEffectOverlay> {
|
||||
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<Uint8List?> _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<Uint8List?>(
|
||||
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<Uint8List?> _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://');
|
||||
}
|
||||
}
|
||||
@ -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<RoomRocketRecordItem>? 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<RoomRocketRecordItem> 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<ScrollNotification>(
|
||||
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;
|
||||
|
||||
@ -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<RoomRocketRewardItem>? rewards;
|
||||
final List<RoomRocketRewardWinner>? 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 <RoomRocketRewardItem>[];
|
||||
}
|
||||
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<RoomRocketRewardItem> rewards;
|
||||
final List<RoomRocketRewardWinner> 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<RoomRocketRewardItem>.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<ScrollNotification>(
|
||||
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<ScrollNotification>(
|
||||
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';
|
||||
|
||||
@ -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 <SCRoomRocketRewardRecordRes>[],
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final String roomId;
|
||||
final RoomRocketRewardItem? selectedReward;
|
||||
final List<SCRoomRocketRewardRecordRes> initialPopupRecords;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
State<RoomRocketRewardDialogLoader> createState() =>
|
||||
_RoomRocketRewardDialogLoaderState();
|
||||
}
|
||||
|
||||
class _RoomRocketRewardDialogLoaderState
|
||||
extends State<RoomRocketRewardDialogLoader> {
|
||||
final List<SCRoomRocketRewardRecordRes> _popupRecords = [];
|
||||
final List<SCRoomRocketRewardRecordRes> _roomRecords = [];
|
||||
List<RoomRocketRewardItem>? _rewards;
|
||||
List<RoomRocketRewardWinner>? _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<void> _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<List<SCRoomRocketRewardRecordRes>> _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<List<SCRoomRocketRewardRecordRes>> _loadRoomRewardRecords(
|
||||
int cursor,
|
||||
) async {
|
||||
if (widget.roomId.trim().isEmpty) {
|
||||
return const <SCRoomRocketRewardRecordRes>[];
|
||||
}
|
||||
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 <SCRoomRocketRewardRecordRes>[];
|
||||
}
|
||||
}
|
||||
|
||||
List<SCRoomRocketRewardRecordRes> _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');
|
||||
}
|
||||
@ -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<MsgItem> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedals(List<WearBadge> 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<WearBadge> fallbackBadges = const <WearBadge>[],
|
||||
}) {
|
||||
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<MsgItem> {
|
||||
)
|
||||
: 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 <WearBadge>[],
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -1853,10 +1850,12 @@ class _MsgItemState extends State<MsgItem> {
|
||||
)
|
||||
: Container(),
|
||||
_buildMedals(
|
||||
user.wearBadge?.where((item) {
|
||||
return item.use ?? false;
|
||||
}).toList() ??
|
||||
[],
|
||||
userId: user.id,
|
||||
fallbackBadges:
|
||||
user.wearBadge
|
||||
?.where((item) => item.use ?? false)
|
||||
.toList() ??
|
||||
const <WearBadge>[],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
267
lib/ui_kit/widgets/room/room_password_dialog.dart
Normal file
267
lib/ui_kit/widgets/room/room_password_dialog.dart
Normal file
@ -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<String?> 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<String> onSuccess;
|
||||
|
||||
static Future<String?> show({required RoomPasswordSubmit onSubmit}) {
|
||||
final completer = Completer<String?>();
|
||||
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<RoomPasswordDialog> createState() => _RoomPasswordDialogState();
|
||||
}
|
||||
|
||||
class _RoomPasswordDialogState extends State<RoomPasswordDialog> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
String _errorText = "";
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<RoomPlayWidget> {
|
||||
bottom: (150 - _rightActionDownShift).w,
|
||||
child: const RoomMusicFloatingEntry(),
|
||||
),
|
||||
PositionedDirectional(
|
||||
end: 65.w,
|
||||
bottom: (150 - _rightActionDownShift).w,
|
||||
Selector<RoomMusicManager, bool>(
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
40
pubspec.lock
40
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:
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
sc_images/room/rocket/rocket_floating_banner_bg.png
Normal file
BIN
sc_images/room/rocket/rocket_floating_banner_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Loading…
x
Reference in New Issue
Block a user