This commit is contained in:
roxy 2026-05-15 17:43:49 +08:00
parent bd6d35ee1b
commit 77d1fd13b1
53 changed files with 7021 additions and 2530 deletions

View File

@ -723,5 +723,8 @@
"duration2": "المدة:{1}",
"roomRocketNoteTitle": "ملاحظة",
"roomRocketNoteSectionTitle": "في كل مرة يتم فيها شراء مستوى VIP",
"roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية."
"roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية.",
"roomRocketRewardEmptyText": "لم تحصل على مكافآت. حظًا أوفر في المرة القادمة.",
"roomRocketRecordEmptyText": "لا توجد سجلات مكافآت",
"launchRocket": "إطلاق الصاروخ"
}

View File

@ -723,5 +723,8 @@
"duration2": "সময়:{1}",
"roomRocketNoteTitle": "নোট",
"roomRocketNoteSectionTitle": "প্রতিবার VIP লেভেল কেনার সময়",
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।"
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।",
"roomRocketRewardEmptyText": "কোনো পুরস্কার পাওয়া যায়নি। পরের বার শুভকামনা।",
"roomRocketRecordEmptyText": "কোনো পুরস্কারের রেকর্ড নেই",
"launchRocket": "রকেট চালু করুন"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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

View File

@ -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 = "";

View File

@ -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');

View 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: () {},
),
),
],
),
);
}
}

View File

@ -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,
);
}
}

View File

@ -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>[];

View File

@ -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) {

View File

@ -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

View File

@ -43,7 +43,7 @@ class _RoomOnlinePageState
if (dialogContext == null) {
return;
}
showBottomInCenterDialog(dialogContext, RoomUserInfoCard(userId: userId));
showBottomInBottomDialog(dialogContext, RoomUserInfoCard(userId: userId));
});
}

View File

@ -98,7 +98,7 @@ class _RoomGiftRankTabPageState
// headdress: userInfo.userProfile?.getHeaddress()?.sourceUrl,
),
onTap: () {
showBottomInCenterDialog(
showBottomInBottomDialog(
navigatorKey.currentState!.context,
RoomUserInfoCard(userId: userProfile?.id),
);

View File

@ -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';
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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),

View File

@ -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),
);

View File

@ -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);

View File

@ -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获取礼物

View File

@ -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;
}

View File

@ -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}) {

View File

@ -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;

View File

@ -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;

View 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;
}
}

View File

@ -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,
});
///

View File

@ -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);

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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',

View File

@ -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, {

View 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;
}
}

View File

@ -247,7 +247,7 @@ class EmptyMaiSelect extends StatelessWidget {
context,
() {
Navigator.of(context).pop();
showBottomInCenterDialog(
showBottomInBottomDialog(
context,
RoomUserInfoCard(userId: clickUser?.id),
);

View File

@ -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';
}

View File

@ -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,
);
}

View File

@ -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._();

View File

@ -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)}...';
}

View File

@ -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://');
}
}

View File

@ -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;

View File

@ -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';

View File

@ -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');
}

View File

@ -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>[],
),
],
),

View 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),
);
}
}

View File

@ -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

View File

@ -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:

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB