This commit is contained in:
roxy 2026-05-14 17:10:53 +08:00
parent 3103d51d4c
commit bd6d35ee1b
74 changed files with 9515 additions and 1587 deletions

View File

@ -720,5 +720,8 @@
"enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.", "enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.",
"voiceConnectionFailedRetry": "فشل الاتصال الصوتي. يرجى المحاولة مرة أخرى.", "voiceConnectionFailedRetry": "فشل الاتصال الصوتي. يرجى المحاولة مرة أخرى.",
"doYouWantToKeepTheDraft": "هل تريد الاحتفاظ بالمسودة؟", "doYouWantToKeepTheDraft": "هل تريد الاحتفاظ بالمسودة؟",
"duration2": "المدة:{1}" "duration2": "المدة:{1}",
} "roomRocketNoteTitle": "ملاحظة",
"roomRocketNoteSectionTitle": "في كل مرة يتم فيها شراء مستوى VIP",
"roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية."
}

View File

@ -720,5 +720,8 @@
"roomMusicListMode": "ক্রম অনুসারে চালান", "roomMusicListMode": "ক্রম অনুসারে চালান",
"roomMusicShuffleMode": "শাফল চালান", "roomMusicShuffleMode": "শাফল চালান",
"roomMusicSingleMode": "একক গান পুনরাবৃত্তি", "roomMusicSingleMode": "একক গান পুনরাবৃত্তি",
"duration2": "সময়:{1}" "duration2": "সময়:{1}",
"roomRocketNoteTitle": "নোট",
"roomRocketNoteSectionTitle": "প্রতিবার VIP লেভেল কেনার সময়",
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।"
} }

View File

@ -720,5 +720,8 @@
"roomMusicListMode": "Play in order", "roomMusicListMode": "Play in order",
"roomMusicShuffleMode": "Shuffle play", "roomMusicShuffleMode": "Shuffle play",
"roomMusicSingleMode": "Single loop", "roomMusicSingleMode": "Single loop",
"duration2": "Duration:{1}" "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."
} }

View File

@ -720,5 +720,8 @@
"roomMusicListMode": "Sırayla çal", "roomMusicListMode": "Sırayla çal",
"roomMusicShuffleMode": "Karışık çal", "roomMusicShuffleMode": "Karışık çal",
"roomMusicSingleMode": "Tek şarkı döngüsü", "roomMusicSingleMode": "Tek şarkı döngüsü",
"duration2": "Süre:{1}" "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."
} }

View File

@ -79,6 +79,18 @@ class SCRoomMsgType {
/// ///
static const String rocketRewardUser = "ROCKET_REWARD_USER"; static const String rocketRewardUser = "ROCKET_REWARD_USER";
///
static const String voiceRoomRocketStatusUpdate =
"VOICE_ROOM_ROCKET_STATUS_UPDATE";
///广
static const String voiceRoomRocketLaunchBroadcast =
"VOICE_ROOM_ROCKET_LAUNCH_BROADCAST";
///
static const String voiceRoomRocketRewardPopup =
"VOICE_ROOM_ROCKET_REWARD_POPUP";
/// ///
static const String managerList = "MANAGER_LIST"; static const String managerList = "MANAGER_LIST";

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart';
void main() {
runApp(const RoomCpProgressDialogPreviewApp());
}
class RoomCpProgressDialogPreviewApp extends StatelessWidget {
const RoomCpProgressDialogPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812),
minTextAdapt: true,
splitScreenMode: true,
builder:
(_, child) => MaterialApp(
debugShowCheckedModeBanner: false,
builder: FlutterSmartDialog.init(),
home: const _RoomCpProgressDialogPreviewPage(),
),
);
}
}
class _RoomCpProgressDialogPreviewPage extends StatelessWidget {
const _RoomCpProgressDialogPreviewPage();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xff1B1224),
body: Stack(
fit: StackFit.expand,
children: [
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xff281B35), Color(0xff120B19)],
),
),
),
const RoomCpProgressDialog(
daysValue: '999',
daysLabel: 'Days',
levelText: 'Lv.1 Simple Love',
currentValue: 1,
targetValue: 1,
expAwayText: '1,000',
nextLevelText: 'Lv. 2',
cancelText: 'Cancel',
leftUser: RoomCpProgressDialogUser(
nickname: 'NameName',
userId: '123456',
levelText: 'Lv.1',
),
rightUser: RoomCpProgressDialogUser(
nickname: 'NameName',
userId: '123456',
levelText: 'Lv.1',
),
),
],
),
);
}
}

View File

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.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_record_dialog.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart';
void main() {
runApp(const RoomRocketDialogPreviewApp());
}
class RoomRocketDialogPreviewApp extends StatelessWidget {
const RoomRocketDialogPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812),
minTextAdapt: true,
splitScreenMode: true,
builder:
(_, child) => MaterialApp(
debugShowCheckedModeBanner: false,
builder: FlutterSmartDialog.init(),
home: const _RoomRocketDialogPreviewPage(),
),
);
}
}
class _RoomRocketDialogPreviewPage extends StatefulWidget {
const _RoomRocketDialogPreviewPage();
@override
State<_RoomRocketDialogPreviewPage> createState() =>
_RoomRocketDialogPreviewPageState();
}
class _RoomRocketDialogPreviewPageState
extends State<_RoomRocketDialogPreviewPage> {
RoomRocketRewardItem? _selectedReward;
_PreviewDialog _dialog = _PreviewDialog.rocket;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
const _RoomBackground(),
if (_dialog == _PreviewDialog.rocket)
RoomRocketDialog(
onRewardSelected:
(reward) => setState(() {
_selectedReward = reward;
_dialog = _PreviewDialog.reward;
}),
onOpenRecord:
() => setState(() => _dialog = _PreviewDialog.record),
onOpenNote: () => setState(() => _dialog = _PreviewDialog.note),
)
else if (_dialog == _PreviewDialog.reward)
RoomRocketRewardDialog(
selectedReward: _selectedReward,
onClose: _backToRocket,
)
else if (_dialog == _PreviewDialog.record)
RoomRocketRecordDialog(onClose: _backToRocket)
else
RoomRocketNoteDialog(onClose: _backToRocket),
],
),
);
}
void _backToRocket() {
setState(() {
_selectedReward = null;
_dialog = _PreviewDialog.rocket;
});
}
}
enum _PreviewDialog { rocket, reward, record, note }
class _RoomBackground extends StatelessWidget {
const _RoomBackground();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2A120E), Color(0xFF071917), Color(0xFF22140B)],
),
),
child: Stack(
children: [
Positioned(
left: 20.w,
top: 48.w,
child: _PreviewLine(width: 104.w, opacity: 0.42),
),
for (var index = 0; index < 8; index++)
Positioned(
left: (40 + (index % 4) * 72).w,
top: (146 + (index ~/ 4) * 84).w,
child: const _PreviewSeat(),
),
Positioned(
left: 0,
right: 0,
bottom: 118.w,
child: _PreviewLine(width: 330.w, opacity: 0.28),
),
],
),
);
}
}
class _PreviewSeat extends StatelessWidget {
const _PreviewSeat();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFFFFCE7C).withValues(alpha: 0.32),
width: 2.w,
),
),
child: SizedBox(width: 44.w, height: 44.w),
);
}
}
class _PreviewLine extends StatelessWidget {
const _PreviewLine({required this.width, required this.opacity});
final double width;
final double opacity;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: width,
height: 1.w,
color: Colors.white.withValues(alpha: opacity),
),
);
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart';
void main() {
runApp(const RoomRocketEmptyDialogPreviewApp());
}
class RoomRocketEmptyDialogPreviewApp extends StatelessWidget {
const RoomRocketEmptyDialogPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812),
minTextAdapt: true,
splitScreenMode: true,
builder:
(_, child) => const MaterialApp(
debugShowCheckedModeBanner: false,
home: _RoomRocketEmptyDialogPreviewPage(),
),
);
}
}
class _RoomRocketEmptyDialogPreviewPage extends StatelessWidget {
const _RoomRocketEmptyDialogPreviewPage();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
const _RoomBackground(),
RoomRocketDialog(
status: SCRoomRocketStatusRes(
level: 1,
energyPercent: 0,
totalContributors: 0,
),
),
],
),
);
}
}
class _RoomBackground extends StatelessWidget {
const _RoomBackground();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(
'sc_images/room/background_examples/bg_example_1.png',
),
fit: BoxFit.cover,
),
),
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.24)),
),
);
}
}

View File

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart';
void main() {
runApp(const RoomRocketNoteDialogPreviewApp());
}
class RoomRocketNoteDialogPreviewApp extends StatelessWidget {
const RoomRocketNoteDialogPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
locale: Locale('en'),
supportedLocales: [
Locale('en'),
Locale('ar'),
Locale('bn'),
Locale('tr'),
],
localizationsDelegates: [
SCAppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: _RoomRocketNoteDialogPreviewPage(),
);
}
}
class _RoomRocketNoteDialogPreviewPage extends StatelessWidget {
const _RoomRocketNoteDialogPreviewPage();
@override
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [_RoomBackground(), RoomRocketNoteDialog()],
),
);
}
}
class _RoomBackground extends StatelessWidget {
const _RoomBackground();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2A120E), Color(0xFF071917), Color(0xFF22140B)],
),
),
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:yumi/modules/room/rocket/room_rocket_rank_page.dart';
void main() {
runApp(const RoomRocketRankPreviewApp());
}
class RoomRocketRankPreviewApp extends StatelessWidget {
const RoomRocketRankPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: RoomRocketRankPage(),
);
}
}

View File

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart';
void main() {
runApp(const RoomRocketRecordDialogPreviewApp());
}
class RoomRocketRecordDialogPreviewApp extends StatelessWidget {
const RoomRocketRecordDialogPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: _RoomRocketRecordDialogPreviewPage(),
);
}
}
class _RoomRocketRecordDialogPreviewPage extends StatelessWidget {
const _RoomRocketRecordDialogPreviewPage();
@override
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [_RoomBackground(), RoomRocketRecordDialog()],
),
);
}
}
class _RoomBackground extends StatelessWidget {
const _RoomBackground();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2A120E), Color(0xFF071917), Color(0xFF22140B)],
),
),
);
}
}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.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';
void main() {
runApp(const RoomRocketRewardDialogPreviewApp());
}
class RoomRocketRewardDialogPreviewApp extends StatelessWidget {
const RoomRocketRewardDialogPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: _RoomRocketRewardDialogPreviewPage(),
);
}
}
class _RoomRocketRewardDialogPreviewPage extends StatelessWidget {
const _RoomRocketRewardDialogPreviewPage();
@override
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
_RoomBackground(),
RoomRocketRewardDialog(
selectedReward: RoomRocketRewardItem(
asset: 'sc_images/room/rocket/reward_thumb.png',
amount: '10000',
),
),
],
),
);
}
}
class _RoomBackground extends StatelessWidget {
const _RoomBackground();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2A120E), Color(0xFF071917), Color(0xFF22140B)],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ import 'package:yumi/modules/room/background/room_background_select_page.dart';
import 'package:yumi/modules/room/background/room_background_upload_page.dart'; import 'package:yumi/modules/room/background/room_background_upload_page.dart';
import 'package:yumi/modules/room/edit/room_edit_page.dart'; import 'package:yumi/modules/room/edit/room_edit_page.dart';
import 'package:yumi/modules/room/music/room_music_page.dart'; import 'package:yumi/modules/room/music/room_music_page.dart';
import 'package:yumi/modules/room/rocket/room_rocket_rank_page.dart';
import 'package:yumi/modules/room/them/room_theme_page.dart'; import 'package:yumi/modules/room/them/room_theme_page.dart';
import 'package:yumi/modules/room/voice_room_page.dart'; import 'package:yumi/modules/room/voice_room_page.dart';
import 'package:yumi/app/routes/sc_router_init.dart'; import 'package:yumi/app/routes/sc_router_init.dart';
@ -28,6 +29,7 @@ class VoiceRoomRoute implements SCIRouterProvider {
static String roomBackgroundPreview = '/room/background/preview'; static String roomBackgroundPreview = '/room/background/preview';
static String roomBackgroundUpload = '/room/background/upload'; static String roomBackgroundUpload = '/room/background/upload';
static String roomMusic = '/room/music'; static String roomMusic = '/room/music';
static String roomRocketRank = '/room/rocket/rank';
static int _voiceRoomRouteDepth = 0; static int _voiceRoomRouteDepth = 0;
static bool get isVoiceRoomOpen => _voiceRoomRouteDepth > 0; static bool get isVoiceRoomOpen => _voiceRoomRouteDepth > 0;
@ -233,6 +235,33 @@ class VoiceRoomRoute implements SCIRouterProvider {
); );
} }
static Future<T?> openRoomRocketRank<T>(
BuildContext context, {
String? roomId,
int initialLevel = 0,
bool rootNavigator = false,
}) {
return Navigator.of(context, rootNavigator: rootNavigator).push<T>(
_buildDarkRoute<T>(
RoomRocketRankPage(roomId: roomId, initialLevel: initialLevel),
settings: RouteSettings(name: roomRocketRank),
),
);
}
static Future<T?> openRoomRocketRankOn<T>(
NavigatorState navigator, {
String? roomId,
int initialLevel = 0,
}) {
return navigator.push<T>(
_buildDarkRoute<T>(
RoomRocketRankPage(roomId: roomId, initialLevel: initialLevel),
settings: RouteSettings(name: roomRocketRank),
),
);
}
@override @override
void initRouter(FluroRouter router) { void initRouter(FluroRouter router) {
router.define( router.define(
@ -265,6 +294,10 @@ class VoiceRoomRoute implements SCIRouterProvider {
roomMusic, roomMusic,
handler: Handler(handlerFunc: (_, params) => const RoomMusicPage()), handler: Handler(handlerFunc: (_, params) => const RoomMusicPage()),
); );
router.define(
roomRocketRank,
handler: Handler(handlerFunc: (_, params) => const RoomRocketRankPage()),
);
router.define( router.define(
roomEdit, roomEdit,
handler: Handler( handler: Handler(

View File

@ -8,6 +8,7 @@ import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/shared/tools/sc_permission_utils.dart'; import 'package:yumi/shared/tools/sc_permission_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.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/local/user_manager.dart';
@ -51,6 +52,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/business_logic/models/res/sc_vip_res.dart';
import '../../shared/tools/sc_room_profile_cache.dart'; import '../../shared/tools/sc_room_profile_cache.dart';
import '../../ui_kit/components/sc_float_ichart.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';
typedef OnSoundVoiceChange = Function(num index, int volum); typedef OnSoundVoiceChange = Function(num index, int volum);
typedef RtcProvider = RealTimeCommunicationManager; typedef RtcProvider = RealTimeCommunicationManager;
@ -288,6 +291,8 @@ class _PendingRoomStartupSeatAction {
} }
class RealTimeCommunicationManager extends ChangeNotifier { class RealTimeCommunicationManager extends ChangeNotifier {
static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog';
static const Duration _micListPollingInterval = Duration(seconds: 2); static const Duration _micListPollingInterval = Duration(seconds: 2);
static const Duration _onlineUsersPollingInterval = Duration(seconds: 3); static const Duration _onlineUsersPollingInterval = Duration(seconds: 3);
static const int _exitNetworkRetryLimit = 3; static const int _exitNetworkRetryLimit = 3;
@ -330,6 +335,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Timer? _joinAgoraTimeoutTimer; Timer? _joinAgoraTimeoutTimer;
Timer? _agoraDisconnectedCleanupTimer; Timer? _agoraDisconnectedCleanupTimer;
Timer? _roomEntryEffectTimer; Timer? _roomEntryEffectTimer;
Timer? _roomRocketLaunchAnimationTimer;
Timer? _roomRedPacketPresenceTimer; Timer? _roomRedPacketPresenceTimer;
Completer<void>? _joinAgoraCompleter; Completer<void>? _joinAgoraCompleter;
Future<RtcEngine>? _rtcEngineInitTask; Future<RtcEngine>? _rtcEngineInitTask;
@ -477,6 +483,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (!enabled) { if (!enabled) {
_roomEntryEffectTimer?.cancel(); _roomEntryEffectTimer?.cancel();
_roomEntryEffectTimer = null; _roomEntryEffectTimer = null;
_roomRocketLaunchAnimationTimer?.cancel();
_roomRocketLaunchAnimationTimer = null;
} }
_roomVisualEffectsEnabled = enabled; _roomVisualEffectsEnabled = enabled;
notifyListeners(); notifyListeners();
@ -2864,29 +2872,118 @@ class RealTimeCommunicationManager extends ChangeNotifier {
} }
void _loadRoomSecondaryData() { void _loadRoomSecondaryData() {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
SCChatRoomRepository().rocketClaim(roomId).catchError((e) {
return true; //
});
SCChatRoomRepository() SCChatRoomRepository()
.rocketClaim(currenRoom?.roomProfile?.roomProfile?.id ?? "") .rocketStatus(roomId)
.catchError((e) {
return true; //
});
SCChatRoomRepository()
.rocketStatus(currenRoom?.roomProfile?.roomProfile?.id ?? "")
.then((res) { .then((res) {
roomRocketStatus = res; roomRocketStatus = res;
notifyListeners(); notifyListeners();
}) })
.catchError((e) {}); .catchError((e) {});
_loadRoomRocketRewardPopups(roomId);
_refreshRoomRedPacketListAfterEntry(); _refreshRoomRedPacketListAfterEntry();
fetchRoomTaskClaimableCount(); fetchRoomTaskClaimableCount();
} }
void _loadRoomRocketRewardPopups(String roomId) {
if (roomId.trim().isEmpty) {
return;
}
SCChatRoomRepository()
.roomRocketRewardPopups(roomId)
.then((res) {
if (res.records.isEmpty ||
roomId !=
(currenRoom?.roomProfile?.roomProfile?.id ?? "").trim()) {
return;
}
final recordIds = res.records
.map((item) => item.id)
.where((item) => item.isNotEmpty)
.toList(growable: false);
SmartDialog.dismiss(tag: _roomRocketRewardDialogTag);
SmartDialog.show(
tag: _roomRocketRewardDialogTag,
alignment: Alignment.center,
debounce: true,
animationType: SmartAnimationType.fade,
maskColor: Colors.transparent,
clickMaskDismiss: false,
builder:
(_) => RoomRocketRewardDialog(
rewards: RoomRocketApiMapper.rewardItemsFromRecords(
res.records,
),
winners: RoomRocketApiMapper.winnersFromRecords(res.records),
loadingText: '',
useMockFallback: false,
onClose: () {
SmartDialog.dismiss(tag: _roomRocketRewardDialogTag);
if (recordIds.isNotEmpty) {
unawaited(
SCChatRoomRepository().ackRoomRocketRewardPopups(
recordIds,
),
);
}
},
),
);
})
.catchError((_) {});
}
/// ///
void updateRoomRocketConfigurationStatus(SCRoomRocketStatusRes res) { void updateRoomRocketConfigurationStatus(SCRoomRocketStatusRes res) {
roomRocketStatus = res; roomRocketStatus = res;
notifyListeners(); notifyListeners();
} }
///
Future<void> refreshRoomRocketStatus({String? roomId}) async {
final resolvedRoomId =
(roomId ?? currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (resolvedRoomId.isEmpty) {
return;
}
final res = await SCChatRoomRepository().rocketStatus(resolvedRoomId);
if (resolvedRoomId !=
(currenRoom?.roomProfile?.roomProfile?.id ?? "").trim()) {
return;
}
roomRocketStatus = res;
notifyListeners();
}
void handleRoomRocketLaunchBroadcast(
RoomRocketLaunchBroadcastMessage launch,
) {
final currentRoomId =
(currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (!launch.isValid || launch.roomId != currentRoomId) {
return;
}
_roomRocketLaunchAnimationTimer?.cancel();
if (shouldShowRoomVisualEffects && isVoiceRoomRouteVisible) {
// TODO(rocket-launch):
// SVGA/VAP/PNG
// RTM
_roomRocketLaunchAnimationTimer = Timer(launch.displayDuration, () {
_roomRocketLaunchAnimationTimer = null;
unawaited(refreshRoomRocketStatus(roomId: launch.roomId));
});
return;
}
unawaited(refreshRoomRocketStatus(roomId: launch.roomId));
}
///线 ///线
Future<void> fetchOnlineUsersList({bool notifyIfUnchanged = true}) async { Future<void> fetchOnlineUsersList({bool notifyIfUnchanged = true}) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
@ -3352,6 +3449,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_stopVoiceRoomForegroundService(); _stopVoiceRoomForegroundService();
_roomEntryEffectTimer?.cancel(); _roomEntryEffectTimer?.cancel();
_roomEntryEffectTimer = null; _roomEntryEffectTimer = null;
_roomRocketLaunchAnimationTimer?.cancel();
_roomRocketLaunchAnimationTimer = null;
_stopRoomRedPacketPresenceHeartbeat(); _stopRoomRedPacketPresenceHeartbeat();
_previewRoomSeatCount = null; _previewRoomSeatCount = null;
_finishRoomStartupSeatLoading(executePendingAction: false); _finishRoomStartupSeatLoading(executePendingAction: false);

View File

@ -47,6 +47,7 @@ import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
import 'package:yumi/shared/data_sources/models/message/big_broadcast_group_message.dart'; import 'package:yumi/shared/data_sources/models/message/big_broadcast_group_message.dart';
import 'package:yumi/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart'; import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart';
import 'package:yumi/shared/business_logic/models/res/broad_cast_mic_change_push.dart' import 'package:yumi/shared/business_logic/models/res/broad_cast_mic_change_push.dart'
@ -59,6 +60,8 @@ 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_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_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/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/room_msg_item.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/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/services/gift/gift_system_manager.dart'; import 'package:yumi/services/gift/gift_system_manager.dart';
@ -103,6 +106,8 @@ class _LuckGiftPushQueueEntry {
} }
class RealTimeMessagingManager extends ChangeNotifier { class RealTimeMessagingManager extends ChangeNotifier {
static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog';
static const int _giftComboMergeWindowMs = 3000; static const int _giftComboMergeWindowMs = 3000;
static const int _maxLuckGiftPushQueueLength = 12; static const int _maxLuckGiftPushQueueLength = 12;
static const int _luckyGiftFloatMinMultiple = 5; static const int _luckyGiftFloatMinMultiple = 5;
@ -1785,6 +1790,123 @@ class RealTimeMessagingManager extends ChangeNotifier {
return null; return null;
} }
void _handleVoiceRoomRocketStatusUpdate(dynamic payload) {
final data = _broadcastPayloadMap(payload);
final roomId = _payloadText(data['roomId']);
if (!_isCurrentVoiceRoom(roomId)) {
return;
}
final currentContext = context;
if (currentContext == null || !currentContext.mounted) {
return;
}
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
currentContext,
listen: false,
);
unawaited(rtcProvider.refreshRoomRocketStatus(roomId: roomId));
}
void _handleVoiceRoomRocketLaunchBroadcast(dynamic payload) {
final launch = RoomRocketLaunchBroadcastMessage.fromJson(
_broadcastPayloadMap(payload),
);
if (!launch.isValid) {
return;
}
final currentContext = context;
if (currentContext != null && currentContext.mounted) {
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
currentContext,
listen: false,
);
final currentRoomId =
(rtcProvider.currenRoom?.roomProfile?.roomProfile?.id ?? '').trim();
if (currentRoomId == launch.roomId) {
rtcProvider.handleRoomRocketLaunchBroadcast(launch);
return;
}
}
OverlayManager().addMessage(_buildRoomRocketLaunchFloatingMessage(launch));
}
void _handleVoiceRoomRocketRewardPopup(dynamic payload) {
final data = _broadcastPayloadMap(payload);
final roomId = _payloadText(data['roomId']);
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) {
return;
}
unawaited(_showRoomRocketRewardPopupDialog(roomId));
}
Future<void> _showRoomRocketRewardPopupDialog(String roomId) async {
try {
final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId);
if (res.records.isEmpty) {
return;
}
final recordIds = res.records
.map((item) => item.id)
.where((item) => item.isNotEmpty)
.toList(growable: false);
SmartDialog.dismiss(tag: _roomRocketRewardDialogTag);
SmartDialog.show(
tag: _roomRocketRewardDialogTag,
alignment: Alignment.center,
debounce: true,
animationType: SmartAnimationType.fade,
maskColor: Colors.transparent,
clickMaskDismiss: false,
builder:
(_) => RoomRocketRewardDialog(
rewards: RoomRocketApiMapper.rewardItemsFromRecords(res.records),
winners: RoomRocketApiMapper.winnersFromRecords(res.records),
loadingText: '',
useMockFallback: false,
onClose: () {
SmartDialog.dismiss(tag: _roomRocketRewardDialogTag);
if (recordIds.isNotEmpty) {
unawaited(
SCChatRoomRepository().ackRoomRocketRewardPopups(recordIds),
);
}
},
),
);
} catch (_) {}
}
SCFloatingMessage _buildRoomRocketLaunchFloatingMessage(
RoomRocketLaunchBroadcastMessage launch,
) {
return SCFloatingMessage(
type: 3,
roomId: launch.roomId,
rocketLevel: launch.safeLevel,
userAvatarUrl: launch.triggerUser.avatar,
userName: launch.triggerUser.nickname,
userId: _firstNonBlank([
launch.triggerUser.account,
launch.triggerUser.userId,
launch.triggerUserId,
]),
giftId: launch.launchNo,
durationSeconds: launch.durationSeconds,
priority: 1000,
);
}
List<String> _payloadStringList(dynamic value) { List<String> _payloadStringList(dynamic value) {
if (value is Iterable) { if (value is Iterable) {
return value return value
@ -1962,6 +2084,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
priority: 1000, priority: 1000,
); );
OverlayManager().addMessage(msg); OverlayManager().addMessage(msg);
} else if (type == SCRoomMsgType.voiceRoomRocketLaunchBroadcast) {
_handleVoiceRoomRocketLaunchBroadcast(data["data"]);
} else if (type == SCRoomMsgType.voiceRoomRocketStatusUpdate) {
_handleVoiceRoomRocketStatusUpdate(data["data"]);
} else if (type == SCRoomMsgType.voiceRoomRocketRewardPopup) {
_handleVoiceRoomRocketRewardPopup(data["data"]);
} else if (type == SCRoomMsgType.roomRedPacket) { } else if (type == SCRoomMsgType.roomRedPacket) {
/// ///
var fData = data["data"]; var fData = data["data"];
@ -2050,6 +2178,18 @@ class RealTimeMessagingManager extends ChangeNotifier {
// //
final data = json.decode(customData); final data = json.decode(customData);
if (data["type"] == SCRoomMsgType.voiceRoomRocketLaunchBroadcast) {
_handleVoiceRoomRocketLaunchBroadcast(data["data"]);
return;
}
if (data["type"] == SCRoomMsgType.voiceRoomRocketStatusUpdate) {
_handleVoiceRoomRocketStatusUpdate(data["data"]);
return;
}
if (data["type"] == SCRoomMsgType.voiceRoomRocketRewardPopup) {
_handleVoiceRoomRocketRewardPopup(data["data"]);
return;
}
if (data["type"] == SCRoomMsgType.roomRedPacket) { if (data["type"] == SCRoomMsgType.roomRedPacket) {
/// ///
var fData = data["data"]; var fData = data["data"];

View File

@ -0,0 +1,403 @@
class SCRoomRocketLevelRes {
SCRoomRocketLevelRes({
required this.id,
required this.level,
required this.needEnergy,
required this.rocketIconUrl,
required this.rocketAnimationUrl,
required this.progressBarUrl,
required this.enabled,
required this.sort,
});
factory SCRoomRocketLevelRes.fromJson(dynamic json) {
final data = _asMap(json);
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']),
enabled: _boolValue(data['enabled'], fallback: true),
sort: _intValue(data['sort']),
);
}
final String id;
final int level;
final int needEnergy;
final String rocketIconUrl;
final String rocketAnimationUrl;
final String progressBarUrl;
final bool enabled;
final int sort;
}
class SCRoomRocketRewardRes {
SCRoomRocketRewardRes({
required this.rewardScene,
required this.rewardType,
required this.rewardItemId,
required this.rewardName,
required this.rewardCover,
required this.rewardAmount,
required this.expireDays,
});
factory SCRoomRocketRewardRes.fromJson(dynamic json) {
final data = _asMap(json);
return SCRoomRocketRewardRes(
rewardScene: _stringValue(data['rewardScene']),
rewardType: _stringValue(data['rewardType']),
rewardItemId: _stringValue(data['rewardItemId']),
rewardName: _stringValue(data['rewardName']),
rewardCover: _stringValue(data['rewardCover']),
rewardAmount: _numValue(data['rewardAmount']) ?? 0,
expireDays: _intValue(data['expireDays']),
);
}
final String rewardScene;
final String rewardType;
final String rewardItemId;
final String rewardName;
final String rewardCover;
final num rewardAmount;
final int expireDays;
String get displayAmount {
if (rewardAmount <= 0) {
return '1';
}
if (rewardAmount % 1 == 0) {
return rewardAmount.toInt().toString();
}
return rewardAmount.toString();
}
}
class SCRoomRocketRewardPreviewRes {
SCRoomRocketRewardPreviewRes({
required this.top1,
required this.ignite,
required this.inRoom,
});
factory SCRoomRocketRewardPreviewRes.fromJson(dynamic json) {
final data = _asMap(json);
return SCRoomRocketRewardPreviewRes(
top1: _rewardList(data['top1']),
ignite: _rewardList(data['ignite']),
inRoom: _rewardList(data['inRoom']),
);
}
final List<SCRoomRocketRewardRes> top1;
final List<SCRoomRocketRewardRes> ignite;
final List<SCRoomRocketRewardRes> inRoom;
List<SCRoomRocketRewardRes> get all => [...top1, ...ignite, ...inRoom];
static List<SCRoomRocketRewardRes> _rewardList(dynamic value) {
if (value is! Iterable) {
return const <SCRoomRocketRewardRes>[];
}
return value.map(SCRoomRocketRewardRes.fromJson).toList(growable: false);
}
}
class SCRoomRocketKingUserRes {
SCRoomRocketKingUserRes({
required this.rank,
required this.userId,
required this.userAvatar,
required this.userNickname,
required this.account,
required this.countryCode,
required this.countryName,
required this.scoreEnergy,
});
factory SCRoomRocketKingUserRes.fromJson(dynamic json) {
final data = _asMap(json);
final profile = _asMap(data['userProfile'] ?? data['user']);
return SCRoomRocketKingUserRes(
rank: _intValue(data['rank'] ?? data['ranking']),
userId: _stringValue(data['userId'] ?? profile['id']),
userAvatar: _stringValue(
data['userAvatar'] ??
data['avatar'] ??
data['avatarUrl'] ??
profile['userAvatar'] ??
profile['avatar'],
),
userNickname: _stringValue(
data['userNickname'] ??
data['nickname'] ??
data['nickName'] ??
profile['userNickname'] ??
profile['nickname'] ??
profile['nickName'],
),
account: _stringValue(data['account'] ?? profile['account']),
countryCode: _stringValue(data['countryCode'] ?? profile['countryCode']),
countryName: _stringValue(data['countryName'] ?? profile['countryName']),
scoreEnergy: _numValue(data['scoreEnergy'] ?? data['score']) ?? 0,
);
}
final int rank;
final String userId;
final String userAvatar;
final String userNickname;
final String account;
final String countryCode;
final String countryName;
final num scoreEnergy;
String get displayName {
if (userNickname.trim().isNotEmpty) {
return userNickname;
}
if (account.trim().isNotEmpty) {
return account;
}
return userId;
}
}
class SCRoomRocketKingRes {
SCRoomRocketKingRes({
required this.roomId,
required this.dayKey,
required this.roundNo,
required this.sourceRoundNo,
required this.level,
required this.podium,
required this.records,
required this.currentUser,
required this.emptyReason,
});
factory SCRoomRocketKingRes.fromJson(dynamic json) {
final data = _asMap(json);
return SCRoomRocketKingRes(
roomId: _stringValue(data['roomId']),
dayKey: _stringValue(data['dayKey']),
roundNo: _intValue(data['roundNo']),
sourceRoundNo: _intValue(data['sourceRoundNo']),
level: _intValue(data['level']),
podium: _kingList(data['podium']),
records: _kingList(data['records']),
currentUser: _kingUser(
data['currentUser'] ?? data['mine'] ?? data['self'] ?? data['myRank'],
),
emptyReason: _stringValue(data['emptyReason']),
);
}
final String roomId;
final String dayKey;
final int roundNo;
final int sourceRoundNo;
final int level;
final List<SCRoomRocketKingUserRes> podium;
final List<SCRoomRocketKingUserRes> records;
final SCRoomRocketKingUserRes? currentUser;
final String emptyReason;
List<SCRoomRocketKingUserRes> get displayRecords {
if (records.isNotEmpty) {
return records;
}
return podium;
}
static List<SCRoomRocketKingUserRes> _kingList(dynamic value) {
if (value is! Iterable) {
return const <SCRoomRocketKingUserRes>[];
}
return value.map(SCRoomRocketKingUserRes.fromJson).toList(growable: false);
}
static SCRoomRocketKingUserRes? _kingUser(dynamic value) {
if (value is! Map) {
return null;
}
return SCRoomRocketKingUserRes.fromJson(value);
}
}
class SCRoomRocketRewardPopupRes {
SCRoomRocketRewardPopupRes({required this.records});
factory SCRoomRocketRewardPopupRes.fromJson(dynamic json) {
final data = _asMap(json);
return SCRoomRocketRewardPopupRes(
records: SCRoomRocketRewardRecordRes.listFromJson(data['records']),
);
}
final List<SCRoomRocketRewardRecordRes> records;
}
class SCRoomRocketRewardRecordsRes {
SCRoomRocketRewardRecordsRes({
required this.records,
required this.cursor,
required this.limit,
required this.emptyReason,
});
factory SCRoomRocketRewardRecordsRes.fromJson(dynamic json) {
final data = _asMap(json);
return SCRoomRocketRewardRecordsRes(
records: SCRoomRocketRewardRecordRes.listFromJson(data['records']),
cursor: _intValue(data['cursor'], fallback: 1),
limit: _intValue(data['limit'], fallback: 20),
emptyReason: _stringValue(data['emptyReason']),
);
}
final List<SCRoomRocketRewardRecordRes> records;
final int cursor;
final int limit;
final String emptyReason;
}
class SCRoomRocketRewardRecordRes {
SCRoomRocketRewardRecordRes({
required this.id,
required this.launchNo,
required this.roomId,
required this.dayKey,
required this.roundNo,
required this.level,
required this.userId,
required this.rewardScene,
required this.rewardType,
required this.rewardItemId,
required this.rewardName,
required this.rewardCover,
required this.rewardAmount,
required this.expireDays,
required this.grantStatus,
required this.popupStatus,
required this.grantTime,
required this.createTime,
required this.errorMessage,
});
factory SCRoomRocketRewardRecordRes.fromJson(dynamic json) {
final data = _asMap(json);
return SCRoomRocketRewardRecordRes(
id: _stringValue(data['id']),
launchNo: _stringValue(data['launchNo']),
roomId: _stringValue(data['roomId']),
dayKey: _stringValue(data['dayKey']),
roundNo: _intValue(data['roundNo']),
level: _intValue(data['level']),
userId: _stringValue(data['userId']),
rewardScene: _stringValue(data['rewardScene']),
rewardType: _stringValue(data['rewardType']),
rewardItemId: _stringValue(data['rewardItemId']),
rewardName: _stringValue(data['rewardName']),
rewardCover: _stringValue(data['rewardCover']),
rewardAmount: _numValue(data['rewardAmount']) ?? 0,
expireDays: _intValue(data['expireDays']),
grantStatus: _stringValue(data['grantStatus']),
popupStatus: _stringValue(data['popupStatus']),
grantTime: _stringValue(data['grantTime']),
createTime: _stringValue(data['createTime']),
errorMessage: _stringValue(data['errorMessage']),
);
}
final String id;
final String launchNo;
final String roomId;
final String dayKey;
final int roundNo;
final int level;
final String userId;
final String rewardScene;
final String rewardType;
final String rewardItemId;
final String rewardName;
final String rewardCover;
final num rewardAmount;
final int expireDays;
final String grantStatus;
final String popupStatus;
final String grantTime;
final String createTime;
final String errorMessage;
String get displayAmount {
if (rewardAmount <= 0) {
return '1';
}
if (rewardAmount % 1 == 0) {
return rewardAmount.toInt().toString();
}
return rewardAmount.toString();
}
static List<SCRoomRocketRewardRecordRes> listFromJson(dynamic value) {
if (value is! Iterable) {
return const <SCRoomRocketRewardRecordRes>[];
}
return value
.map(SCRoomRocketRewardRecordRes.fromJson)
.toList(growable: false);
}
}
Map<String, dynamic> _asMap(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>{};
}
String _stringValue(dynamic value) {
if (value == null) {
return '';
}
return value.toString().trim();
}
int _intValue(dynamic value, {int fallback = 0}) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(_stringValue(value)) ?? fallback;
}
num? _numValue(dynamic value) {
if (value is num) {
return value;
}
return num.tryParse(_stringValue(value));
}
bool _boolValue(dynamic value, {bool fallback = false}) {
if (value is bool) {
return value;
}
final text = _stringValue(value).toLowerCase();
if (text == 'true' || text == '1') {
return true;
}
if (text == 'false' || text == '0') {
return false;
}
return fallback;
}

View File

@ -1,145 +1,256 @@
/// roomId : 0 import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart';
/// date : ""
/// level : 0 /// roomId : 0
/// levelName : "" /// date : ""
/// currentEnergy : 0 /// level : 0
/// maxEnergy : 0 /// levelName : ""
/// energyPercent : 0.0 /// currentEnergy : 0
/// status : "" /// maxEnergy : 0
/// statusDesc : "" /// energyPercent : 0.0
/// totalContributors : 0 /// status : ""
/// launchTime : "" /// statusDesc : ""
/// claimExpireTime : "" /// totalContributors : 0
/// claimCount : 0 /// launchTime : ""
/// canClaim : false /// claimExpireTime : ""
/// hasClaimed : false /// claimCount : 0
/// myContribution : 0 /// canClaim : false
/// myContributionRate : 0.0 /// hasClaimed : false
/// myContribution : 0
class SCRoomRocketStatusRes { /// myContributionRate : 0.0
SCRoomRocketStatusRes({
String? roomId, class SCRoomRocketStatusRes {
String? date, SCRoomRocketStatusRes({
num? level, String? roomId,
String? levelName, String? date,
int? currentEnergy, num? level,
int? maxEnergy, String? levelName,
num? energyPercent, int? currentEnergy,
String? status, int? maxEnergy,
String? statusDesc, num? energyPercent,
num? totalContributors, String? status,
String? launchTime, String? statusDesc,
String? claimExpireTime, num? totalContributors,
num? claimCount, String? launchTime,
bool? canClaim, String? claimExpireTime,
bool? hasClaimed, num? claimCount,
num? myContribution, bool? canClaim,
num? myContributionRate, bool? hasClaimed,
}) { num? myContribution,
_roomId = roomId; num? myContributionRate,
_date = date; int? roundNo,
_level = level; bool? displayRound,
_levelName = levelName; int? currentLevel,
_currentEnergy = currentEnergy; int? needEnergy,
_maxEnergy = maxEnergy; num? displayPercent,
_energyPercent = energyPercent; bool? shake,
_status = status; List<SCRoomRocketLevelRes>? levels,
_statusDesc = statusDesc; SCRoomRocketRewardPreviewRes? rewardPreview,
_totalContributors = totalContributors; List<SCRoomRocketKingUserRes>? rocketKings,
_claimExpireTime = claimExpireTime; }) {
_claimCount = claimCount; _roomId = roomId;
_canClaim = canClaim; _date = date;
_hasClaimed = hasClaimed; _level = level;
_myContribution = myContribution; _levelName = levelName;
_myContributionRate = myContributionRate; _currentEnergy = currentEnergy;
} _maxEnergy = maxEnergy;
_energyPercent = energyPercent;
SCRoomRocketStatusRes.fromJson(dynamic json) { _status = status;
_roomId = json['roomId']; _statusDesc = statusDesc;
_date = json['date']; _totalContributors = totalContributors;
_level = json['level']; _claimExpireTime = claimExpireTime;
_levelName = json['levelName']; _claimCount = claimCount;
_currentEnergy = json['currentEnergy']; _canClaim = canClaim;
_maxEnergy = json['maxEnergy']; _hasClaimed = hasClaimed;
_energyPercent = json['energyPercent']; _myContribution = myContribution;
_status = json['status']; _myContributionRate = myContributionRate;
_statusDesc = json['statusDesc']; _roundNo = roundNo;
_totalContributors = json['totalContributors']; _displayRound = displayRound;
_claimExpireTime = json['claimExpireTime']; _currentLevel = currentLevel;
_claimCount = json['claimCount']; _needEnergy = needEnergy;
_canClaim = json['canClaim']; _displayPercent = displayPercent;
_hasClaimed = json['hasClaimed']; _shake = shake;
_myContribution = json['myContribution']; _levels = levels;
_myContributionRate = json['myContributionRate']; _rewardPreview = rewardPreview;
} _rocketKings = rocketKings;
}
String? _roomId;
String? _date; SCRoomRocketStatusRes.fromJson(dynamic json) {
num? _level; _roomId = json['roomId'];
String? _levelName; _date = json['date'] ?? json['dayKey'];
int? _currentEnergy; _roundNo = _intValue(json['roundNo']);
int? _maxEnergy; _displayRound = _boolValue(json['displayRound']);
num? _energyPercent; _currentLevel = _intValue(json['currentLevel']);
String? _status; _needEnergy = _intValue(json['needEnergy']);
String? _statusDesc; _displayPercent = _numValue(json['displayPercent']);
num? _totalContributors; _shake = _boolValue(json['shake']);
String? _claimExpireTime; _level = json['level'] ?? _currentLevel;
num? _claimCount; _levelName = json['levelName'];
bool? _canClaim; _currentEnergy = _intValue(json['currentEnergy']);
bool? _hasClaimed; _maxEnergy = _intValue(json['maxEnergy']) ?? _needEnergy;
num? _myContribution; _energyPercent = _numValue(json['energyPercent']) ?? _displayPercent;
num? _myContributionRate; _status = json['status'];
_statusDesc = json['statusDesc'];
String? get roomId => _roomId; _rocketKings = _kingList(json['rocketKings']);
_totalContributors =
String? get date => _date; json['totalContributors'] ?? (_rocketKings?.length ?? 0);
_claimExpireTime = json['claimExpireTime'];
num? get level => _level; _claimCount = json['claimCount'];
_canClaim = json['canClaim'];
String? get levelName => _levelName; _hasClaimed = json['hasClaimed'];
_myContribution = json['myContribution'];
int? get currentEnergy => _currentEnergy; _myContributionRate = json['myContributionRate'];
_levels = _levelList(json['levels']);
int? get maxEnergy => _maxEnergy; _rewardPreview =
json['rewardPreview'] == null
num? get energyPercent => _energyPercent; ? null
: SCRoomRocketRewardPreviewRes.fromJson(json['rewardPreview']);
String? get status => _status; }
String? get statusDesc => _statusDesc; String? _roomId;
String? _date;
num? get totalContributors => _totalContributors; num? _level;
String? _levelName;
String? get claimExpireTime => _claimExpireTime; int? _currentEnergy;
int? _maxEnergy;
num? get claimCount => _claimCount; num? _energyPercent;
String? _status;
bool? get canClaim => _canClaim; String? _statusDesc;
num? _totalContributors;
bool? get hasClaimed => _hasClaimed; String? _claimExpireTime;
num? _claimCount;
num? get myContribution => _myContribution; bool? _canClaim;
bool? _hasClaimed;
num? get myContributionRate => _myContributionRate; num? _myContribution;
num? _myContributionRate;
Map<String, dynamic> toJson() { int? _roundNo;
final map = <String, dynamic>{}; bool? _displayRound;
map['roomId'] = _roomId; int? _currentLevel;
map['date'] = _date; int? _needEnergy;
map['level'] = _level; num? _displayPercent;
map['levelName'] = _levelName; bool? _shake;
map['currentEnergy'] = _currentEnergy; List<SCRoomRocketLevelRes>? _levels;
map['maxEnergy'] = _maxEnergy; SCRoomRocketRewardPreviewRes? _rewardPreview;
map['energyPercent'] = _energyPercent; List<SCRoomRocketKingUserRes>? _rocketKings;
map['status'] = _status;
map['statusDesc'] = _statusDesc; String? get roomId => _roomId;
map['totalContributors'] = _totalContributors;
map['claimExpireTime'] = _claimExpireTime; String? get date => _date;
map['claimCount'] = _claimCount;
map['canClaim'] = _canClaim; num? get level => _level;
map['hasClaimed'] = _hasClaimed;
map['myContribution'] = _myContribution; String? get levelName => _levelName;
map['myContributionRate'] = _myContributionRate;
return map; int? get currentEnergy => _currentEnergy;
}
} int? get maxEnergy => _maxEnergy;
num? get energyPercent => _energyPercent;
String? get status => _status;
String? get statusDesc => _statusDesc;
num? get totalContributors => _totalContributors;
String? get claimExpireTime => _claimExpireTime;
num? get claimCount => _claimCount;
bool? get canClaim => _canClaim;
bool? get hasClaimed => _hasClaimed;
num? get myContribution => _myContribution;
num? get myContributionRate => _myContributionRate;
int? get roundNo => _roundNo;
bool? get displayRound => _displayRound;
int? get currentLevel => _currentLevel;
int? get needEnergy => _needEnergy;
num? get displayPercent => _displayPercent;
bool? get shake => _shake;
List<SCRoomRocketLevelRes>? get levels => _levels;
SCRoomRocketRewardPreviewRes? get rewardPreview => _rewardPreview;
List<SCRoomRocketKingUserRes>? get rocketKings => _rocketKings;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['roomId'] = _roomId;
map['date'] = _date;
map['level'] = _level;
map['levelName'] = _levelName;
map['currentEnergy'] = _currentEnergy;
map['maxEnergy'] = _maxEnergy;
map['energyPercent'] = _energyPercent;
map['status'] = _status;
map['statusDesc'] = _statusDesc;
map['totalContributors'] = _totalContributors;
map['claimExpireTime'] = _claimExpireTime;
map['claimCount'] = _claimCount;
map['canClaim'] = _canClaim;
map['hasClaimed'] = _hasClaimed;
map['myContribution'] = _myContribution;
map['myContributionRate'] = _myContributionRate;
map['roundNo'] = _roundNo;
map['displayRound'] = _displayRound;
map['currentLevel'] = _currentLevel;
map['needEnergy'] = _needEnergy;
map['displayPercent'] = _displayPercent;
map['shake'] = _shake;
return map;
}
static int? _intValue(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString().trim() ?? '');
}
static num? _numValue(dynamic value) {
if (value is num) {
return value;
}
return num.tryParse(value?.toString().trim() ?? '');
}
static bool? _boolValue(dynamic value) {
if (value is bool) {
return value;
}
final text = value?.toString().trim().toLowerCase();
if (text == 'true' || text == '1') {
return true;
}
if (text == 'false' || text == '0') {
return false;
}
return null;
}
static List<SCRoomRocketLevelRes>? _levelList(dynamic value) {
if (value is! Iterable) {
return null;
}
return value.map(SCRoomRocketLevelRes.fromJson).toList(growable: false);
}
static List<SCRoomRocketKingUserRes>? _kingList(dynamic value) {
if (value is! Iterable) {
return null;
}
return value.map(SCRoomRocketKingUserRes.fromJson).toList(growable: false);
}
}

View File

@ -23,6 +23,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_sent_re
import 'package:yumi/shared/business_logic/models/res/room_res.dart'; import 'package:yumi/shared/business_logic/models/res/room_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_reward_info_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_reward_info_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_config_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_config_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_rocket_status_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_task_claimable_count_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_task_claimable_count_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_task_list_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_task_list_res.dart';
@ -192,6 +193,31 @@ abstract class SocialChatRoomRepository {
/// ///
Future<SCRoomRocketStatusRes> rocketStatus(String roomId); Future<SCRoomRocketStatusRes> rocketStatus(String roomId);
///
Future<SCRoomRocketKingRes> roomRocketKing(
String roomId, {
int level = 1,
int roundNo = 0,
int cursor = 1,
int limit = 100,
});
///
Future<SCRoomRocketRewardPopupRes> roomRocketRewardPopups(String roomId);
///
Future<bool> ackRoomRocketRewardPopups(List<String> rewardRecordIds);
///
Future<SCRoomRocketRewardRecordsRes> roomRocketRewardRecords(
String roomId, {
int cursor = 1,
int limit = 20,
});
///
Future<dynamic> roomRocketRule();
/// ///
Future<List<SCRoomRocketConfigRes>> rocketConfigEnabled(); Future<List<SCRoomRocketConfigRes>> rocketConfigEnabled();

View File

@ -0,0 +1,114 @@
class RoomRocketLaunchBroadcastMessage {
const RoomRocketLaunchBroadcastMessage({
required this.roomId,
required this.roomAccount,
required this.roomName,
required this.roundNo,
required this.level,
required this.durationSeconds,
required this.triggerUser,
required this.triggerUserId,
required this.launchNo,
});
factory RoomRocketLaunchBroadcastMessage.fromJson(dynamic json) {
final data = _asMap(json);
return RoomRocketLaunchBroadcastMessage(
roomId: _stringValue(data['roomId']),
roomAccount: _stringValue(data['roomAccount']),
roomName: _stringValue(data['roomName']),
roundNo: _intValue(data['roundNo']),
level: _intValue(data['level'], fallback: 1),
durationSeconds: _intValue(data['durationSeconds'], fallback: 7),
triggerUser: RoomRocketLaunchTriggerUser.fromJson(data['triggerUser']),
triggerUserId: _stringValue(data['triggerUserId']),
launchNo: _stringValue(data['launchNo']),
);
}
final String roomId;
final String roomAccount;
final String roomName;
final int roundNo;
final int level;
final int durationSeconds;
final RoomRocketLaunchTriggerUser triggerUser;
final String triggerUserId;
final String launchNo;
bool get isValid => roomId.isNotEmpty;
int get safeLevel {
if (level < 1) {
return 1;
}
if (level > 6) {
return 6;
}
return level;
}
Duration get displayDuration {
if (durationSeconds <= 0) {
return const Duration(seconds: 7);
}
return Duration(seconds: durationSeconds);
}
}
class RoomRocketLaunchTriggerUser {
const RoomRocketLaunchTriggerUser({
required this.userId,
required this.nickname,
required this.avatar,
required this.account,
required this.countryCode,
required this.countryName,
});
factory RoomRocketLaunchTriggerUser.fromJson(dynamic json) {
final data = _asMap(json);
return RoomRocketLaunchTriggerUser(
userId: _stringValue(data['userId']),
nickname: _stringValue(data['nickname']),
avatar: _stringValue(data['avatar']),
account: _stringValue(data['account']),
countryCode: _stringValue(data['countryCode']),
countryName: _stringValue(data['countryName']),
);
}
final String userId;
final String nickname;
final String avatar;
final String account;
final String countryCode;
final String countryName;
}
Map<String, dynamic> _asMap(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>{};
}
String _stringValue(dynamic value) {
if (value == null) {
return '';
}
return value.toString().trim();
}
int _intValue(dynamic value, {int fallback = 0}) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(_stringValue(value)) ?? fallback;
}

View File

@ -16,6 +16,7 @@ class SCFloatingMessage {
num? coins; num? coins;
num? number; num? number;
num? multiple; num? multiple;
num? durationSeconds;
String? broadcastScope; // region IM String? broadcastScope; // region IM
int priority = 10; // int priority = 10; //
@ -34,6 +35,7 @@ class SCFloatingMessage {
this.giftId = '', this.giftId = '',
this.number = 0, this.number = 0,
this.coins = 0, this.coins = 0,
this.durationSeconds,
this.priority = 10, this.priority = 10,
this.multiple = 10, this.multiple = 10,
this.broadcastScope = '', this.broadcastScope = '',
@ -54,6 +56,7 @@ class SCFloatingMessage {
giftId = json['giftId']; giftId = json['giftId'];
coins = json['coins']; coins = json['coins'];
number = json['number']; number = json['number'];
durationSeconds = json['durationSeconds'];
priority = json['priority']; priority = json['priority'];
multiple = json['multiple']; multiple = json['multiple'];
broadcastScope = json['broadcastScope']; broadcastScope = json['broadcastScope'];
@ -77,6 +80,7 @@ class SCFloatingMessage {
map['giftId'] = giftId; map['giftId'] = giftId;
map['coins'] = coins; map['coins'] = coins;
map['number'] = number; map['number'] = number;
map['durationSeconds'] = durationSeconds;
map['priority'] = priority; map['priority'] = priority;
map['multiple'] = multiple; map['multiple'] = multiple;
map['broadcastScope'] = broadcastScope; map['broadcastScope'] = broadcastScope;

View File

@ -162,14 +162,21 @@ class OverlayManager {
} }
String? _floatingDedupKeyFor(SCFloatingMessage message) { String? _floatingDedupKeyFor(SCFloatingMessage message) {
if (message.type != 4) { if (message.type == 3) {
return null; final launchNo = message.giftId?.trim() ?? '';
if (launchNo.isEmpty) {
return null;
}
return 'room_rocket_launch|$launchNo';
} }
final packetId = message.toUserId?.trim() ?? ''; if (message.type == 4) {
if (packetId.isEmpty) { final packetId = message.toUserId?.trim() ?? '';
return null; if (packetId.isEmpty) {
return null;
}
return 'room_red_packet|$packetId';
} }
return 'room_red_packet|$packetId'; return null;
} }
String _compensationKeyFor(SCFloatingMessage message) { String _compensationKeyFor(SCFloatingMessage message) {

View File

@ -0,0 +1,728 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
const String _roomCpInviteAssetBase = 'sc_images/room/cp_invite';
enum RoomCpInviteDialogStyle { waiting, incoming }
class RoomCpInviteDialogUser {
const RoomCpInviteDialogUser({required this.name, this.avatarUrl = ''});
final String name;
final String avatarUrl;
}
class RoomCpInviteDialog extends StatefulWidget {
const RoomCpInviteDialog({
super.key,
required this.inviter,
required this.receiver,
this.style = RoomCpInviteDialogStyle.waiting,
this.countdownSeconds = 86399,
this.dismissTag = dialogTag,
this.autoDismiss = true,
this.dismissOnAction = true,
this.titleText,
this.descriptionText,
this.bottomText,
this.acceptText = 'Accept',
this.rejectText = 'Reject',
this.onAccept,
this.onReject,
this.onClose,
this.onTimeout,
});
static const String dialogTag = 'showRoomCpInviteDialog';
final RoomCpInviteDialogUser inviter;
final RoomCpInviteDialogUser receiver;
final RoomCpInviteDialogStyle style;
final int countdownSeconds;
final String dismissTag;
final bool autoDismiss;
final bool dismissOnAction;
final String? titleText;
final String? descriptionText;
final String? bottomText;
final String acceptText;
final String rejectText;
final VoidCallback? onAccept;
final VoidCallback? onReject;
final VoidCallback? onClose;
final VoidCallback? onTimeout;
static Future<void> show(
BuildContext context, {
required RoomCpInviteDialogUser inviter,
required RoomCpInviteDialogUser receiver,
RoomCpInviteDialogStyle style = RoomCpInviteDialogStyle.waiting,
int countdownSeconds = 86399,
String dismissTag = dialogTag,
bool autoDismiss = true,
bool dismissOnAction = true,
String? titleText,
String? descriptionText,
String? bottomText,
String acceptText = 'Accept',
String rejectText = 'Reject',
VoidCallback? onAccept,
VoidCallback? onReject,
VoidCallback? onClose,
VoidCallback? onTimeout,
}) async {
await SmartDialog.dismiss(tag: dismissTag);
await SmartDialog.show(
tag: dismissTag,
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
maskColor: Colors.transparent,
builder: (_) {
return RoomCpInviteDialog(
inviter: inviter,
receiver: receiver,
style: style,
countdownSeconds: countdownSeconds,
dismissTag: dismissTag,
autoDismiss: autoDismiss,
dismissOnAction: dismissOnAction,
titleText: titleText,
descriptionText: descriptionText,
bottomText: bottomText,
acceptText: acceptText,
rejectText: rejectText,
onAccept: onAccept,
onReject: onReject,
onClose: onClose,
onTimeout: onTimeout,
);
},
);
}
@override
State<RoomCpInviteDialog> createState() => _RoomCpInviteDialogState();
}
class _RoomCpInviteDialogState extends State<RoomCpInviteDialog> {
static const Color _primaryTextColor = Color(0xff543356);
static const Color _secondaryTextColor = Color(0xff67576F);
static const Color _pinkTextColor = Color(0xffFF2A70);
Timer? _timer;
late int _secondsLeft;
bool get _isIncoming => widget.style == RoomCpInviteDialogStyle.incoming;
@override
void initState() {
super.initState();
_secondsLeft = widget.countdownSeconds.clamp(0, 359999).toInt();
if (widget.autoDismiss && _secondsLeft > 0) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
return;
}
if (_secondsLeft <= 1) {
_timer?.cancel();
widget.onTimeout?.call();
SmartDialog.dismiss(tag: widget.dismissTag);
return;
}
setState(() {
_secondsLeft -= 1;
});
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenWidth = ScreenUtil().screenWidth;
final screenHeight = ScreenUtil().screenHeight;
return Material(
type: MaterialType.transparency,
child: SizedBox(
width: screenWidth,
height: screenHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: ColoredBox(color: Colors.black.withValues(alpha: 0.6)),
),
_buildDialogBackground(),
_buildTitle(),
_buildGift(),
_buildAvatars(),
_buildUserNames(),
_buildContentPanel(),
_buildBottomContent(),
_buildCloseButton(),
],
),
),
);
}
Widget _buildDialogBackground() {
return Positioned(
left: 18.w,
top: 150.w,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_background.png',
width: 340.w,
height: 458.w,
fit: BoxFit.fill,
),
);
}
Widget _buildTitle() {
return Positioned(
left: 83.w,
top: 221.w,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_title.png',
width: 221.w,
height: 60.w,
fit: BoxFit.contain,
),
);
}
Widget _buildGift() {
return Positioned.fill(
child: Stack(
children: [
Positioned(
left: 135.w,
top: 277.w,
child: SizedBox(
width: 106.w,
height: 70.w,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_gift_burst.png',
fit: BoxFit.cover,
),
),
),
Positioned(
left: 148.w,
top: 333.w,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_gift_floor.png',
width: 80.w,
height: 41.w,
fit: BoxFit.contain,
),
),
Positioned(
left: 166.w,
top: 310.w,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_gift.png',
width: 44.w,
height: 44.w,
fit: BoxFit.contain,
),
),
],
),
);
}
Widget _buildAvatars() {
return Positioned.fill(
child: Stack(
children: [
_PositionedAvatar(
left: 76.w,
top: 296.w,
avatarLeft: 78.w,
avatarTop: 301.w,
user: widget.inviter,
),
_PositionedAvatar(
left: 241.w,
top: 296.w,
avatarLeft: 243.w,
avatarTop: 301.w,
user: widget.receiver,
),
],
),
);
}
Widget _buildUserNames() {
return Stack(
children: [
_PositionedText(
left: 77.w,
top: 370.w,
width: 56.w,
height: 10.w,
text: _safeName(widget.inviter.name),
fontSize: 10.sp,
color: _primaryTextColor,
fontWeight: FontWeight.w400,
),
_PositionedText(
left: 243.w,
top: 370.w,
width: 56.w,
height: 10.w,
text: _safeName(widget.receiver.name),
fontSize: 10.sp,
color: _primaryTextColor,
fontWeight: FontWeight.w400,
),
],
);
}
Widget _buildContentPanel() {
return Positioned(
left: 58.w,
top: 398.w,
child: SizedBox(
width: 260.w,
height: 111.w,
child: Stack(
children: [
Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_panel.png',
width: 260.w,
height: 111.w,
fit: BoxFit.fill,
),
Positioned(
left: 42.w,
top: 13.w,
child: SizedBox(
width: 176.w,
height: 16.w,
child: Text(
_panelTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: _primaryTextColor,
fontSize: 16.sp,
fontWeight: FontWeight.w400,
height: 1,
decoration: TextDecoration.none,
),
),
),
),
Positioned(
left: 45.w,
top: 37.w,
child: SizedBox(
width: 170.w,
height: 12.w,
child: _DescriptionText(text: _descriptionText),
),
),
Positioned(
left: 50.w,
top: 59.w,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_divider.png',
width: 160.w,
height: 11.w,
fit: BoxFit.contain,
),
),
Positioned(
left: 65.w,
top: 74.w,
child: _CountdownRow(text: _countdownText),
),
],
),
),
);
}
Widget _buildBottomContent() {
if (_isIncoming) {
return Positioned(
left: 73.w,
top: 536.w,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ActionButton(
asset: '$_roomCpInviteAssetBase/sc_cp_invite_reject_btn.png',
label: widget.rejectText,
width: 108.w,
height: 34.w,
color: _pinkTextColor,
onTap: _handleReject,
),
SizedBox(width: 14.w),
_ActionButton(
asset: '$_roomCpInviteAssetBase/sc_cp_invite_accept_btn.png',
label: widget.acceptText,
width: 108.w,
height: 34.w,
color: Colors.white,
leadingPadding: 22.w,
onTap: _handleAccept,
),
],
),
);
}
return _PositionedText(
left: 90.w,
top: 536.w,
width: 196.w,
height: 12.w,
text: widget.bottomText ?? 'Wait for the other party to accept...',
fontSize: 12.sp,
color: _primaryTextColor,
fontWeight: FontWeight.w400,
);
}
Widget _buildCloseButton() {
return Positioned(
left: 168.w,
top: 627.w,
child: SCDebounceWidget(
onTap: _handleClose,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_close.png',
width: 40.w,
height: 51.w,
fit: BoxFit.contain,
),
),
);
}
String get _panelTitle {
final title = widget.titleText?.trim();
if (title != null && title.isNotEmpty) {
return title;
}
return _safeName(_isIncoming ? widget.inviter.name : widget.receiver.name);
}
String get _descriptionText {
final description = widget.descriptionText?.trim();
if (description != null && description.isNotEmpty) {
return description;
}
return _isIncoming
? 'Invite you to become Couple'
: 'We invite you to become Couple';
}
String get _countdownText {
final hours = (_secondsLeft ~/ 3600).toString().padLeft(2, '0');
final minutes = ((_secondsLeft % 3600) ~/ 60).toString().padLeft(2, '0');
final seconds = (_secondsLeft % 60).toString().padLeft(2, '0');
return 'Countdown: $hours:$minutes:$seconds';
}
String _safeName(String value) {
final trimmed = value.trim();
return trimmed.isEmpty ? 'NameName' : trimmed;
}
void _handleAccept() {
widget.onAccept?.call();
_dismissAfterAction();
}
void _handleReject() {
widget.onReject?.call();
_dismissAfterAction();
}
void _handleClose() {
widget.onClose?.call();
SmartDialog.dismiss(tag: widget.dismissTag);
}
void _dismissAfterAction() {
if (widget.dismissOnAction) {
SmartDialog.dismiss(tag: widget.dismissTag);
}
}
}
class _PositionedAvatar extends StatelessWidget {
const _PositionedAvatar({
required this.left,
required this.top,
required this.avatarLeft,
required this.avatarTop,
required this.user,
});
final double left;
final double top;
final double avatarLeft;
final double avatarTop;
final RoomCpInviteDialogUser user;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: Stack(
children: [
Positioned(
left: avatarLeft,
top: avatarTop,
child: ClipOval(child: _AvatarImage(user: user)),
),
Positioned(
left: left,
top: top,
child: Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_avatar_frame.png',
width: 59.w,
height: 69.w,
fit: BoxFit.contain,
),
),
],
),
);
}
}
class _AvatarImage extends StatelessWidget {
const _AvatarImage({required this.user});
final RoomCpInviteDialogUser user;
@override
Widget build(BuildContext context) {
if (user.avatarUrl.trim().isNotEmpty) {
return netImage(
url: user.avatarUrl,
width: 55.w,
height: 55.w,
fit: BoxFit.cover,
defaultImg: 'sc_images/general/sc_icon_avar_defalt.png',
);
}
return Image.asset(
'sc_images/general/sc_icon_avar_defalt.png',
width: 55.w,
height: 55.w,
fit: BoxFit.cover,
);
}
}
class _PositionedText extends StatelessWidget {
const _PositionedText({
required this.left,
required this.top,
required this.width,
required this.height,
required this.text,
required this.fontSize,
required this.color,
required this.fontWeight,
});
final double left;
final double top;
final double width;
final double height;
final String text;
final double fontSize;
final Color color;
final FontWeight fontWeight;
@override
Widget build(BuildContext context) {
return Positioned(
left: left,
top: top,
child: SizedBox(
width: width,
height: height,
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: color,
fontSize: fontSize,
fontWeight: fontWeight,
height: 1,
decoration: TextDecoration.none,
),
),
),
);
}
}
class _DescriptionText extends StatelessWidget {
const _DescriptionText({required this.text});
final String text;
@override
Widget build(BuildContext context) {
const couple = 'Couple';
final coupleIndex = text.indexOf(couple);
if (coupleIndex < 0) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: _style(_RoomCpInviteDialogState._secondaryTextColor),
);
}
return Text.rich(
TextSpan(
children: [
TextSpan(text: text.substring(0, coupleIndex)),
TextSpan(
text: couple,
style: _style(_RoomCpInviteDialogState._pinkTextColor),
),
TextSpan(text: text.substring(coupleIndex + couple.length)),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: _style(_RoomCpInviteDialogState._secondaryTextColor),
);
}
TextStyle _style(Color color) {
return TextStyle(
color: color,
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
decoration: TextDecoration.none,
);
}
}
class _CountdownRow extends StatelessWidget {
const _CountdownRow({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 130.w,
height: 24.w,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'$_roomCpInviteAssetBase/sc_cp_invite_countdown.png',
width: 24.w,
height: 24.w,
fit: BoxFit.contain,
),
SizedBox(width: 4.w),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
text,
maxLines: 1,
style: TextStyle(
color: _RoomCpInviteDialogState._pinkTextColor,
fontSize: 10.sp,
fontWeight: FontWeight.w500,
height: 1,
decoration: TextDecoration.none,
),
),
),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.asset,
required this.label,
required this.width,
required this.height,
required this.color,
required this.onTap,
this.leadingPadding = 0,
});
final String asset;
final String label;
final double width;
final double height;
final Color color;
final VoidCallback onTap;
final double leadingPadding;
@override
Widget build(BuildContext context) {
return SCDebounceWidget(
onTap: onTap,
child: SizedBox(
width: width,
height: height,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(asset, width: width, height: height, fit: BoxFit.fill),
Padding(
padding: EdgeInsetsDirectional.only(start: leadingPadding),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 12.sp,
fontWeight: FontWeight.w700,
height: 1,
decoration: TextDecoration.none,
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,488 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
const String _roomCpProgressAssetBase = 'sc_images/room/cp_progress';
/// User payload reserved for the voice-room CP progress API.
class RoomCpProgressDialogUser {
const RoomCpProgressDialogUser({
this.nickname = '',
this.userId = '',
this.avatarUrl = '',
this.levelText = '',
});
final String nickname;
final String userId;
final String avatarUrl;
final String levelText;
}
/// Voice-room CP progress popup.
class RoomCpProgressDialog extends StatelessWidget {
const RoomCpProgressDialog({
super.key,
required this.leftUser,
required this.rightUser,
this.daysValue = '999',
this.daysLabel = 'Days',
this.levelText = 'Lv.1 Simple Love',
this.currentValue = 1,
this.targetValue = 1,
this.expAwayText = '1,000',
this.nextLevelText = 'Lv. 2',
this.cancelText = 'Cancel',
this.dismissTag = dialogTag,
this.onCancel,
});
static const String dialogTag = 'showRoomCpProgressDialog';
final RoomCpProgressDialogUser leftUser;
final RoomCpProgressDialogUser rightUser;
final String daysValue;
final String daysLabel;
final String levelText;
final double currentValue;
final double targetValue;
final String expAwayText;
final String nextLevelText;
final String cancelText;
final String dismissTag;
final VoidCallback? onCancel;
static Future<void> show(
BuildContext context, {
required RoomCpProgressDialogUser leftUser,
required RoomCpProgressDialogUser rightUser,
String daysValue = '999',
String daysLabel = 'Days',
String levelText = 'Lv.1 Simple Love',
double currentValue = 1,
double targetValue = 1,
String expAwayText = '1,000',
String nextLevelText = 'Lv. 2',
String cancelText = 'Cancel',
String dismissTag = dialogTag,
VoidCallback? onCancel,
}) async {
await SmartDialog.dismiss(tag: dismissTag);
await SmartDialog.show(
tag: dismissTag,
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
maskColor: Colors.black.withValues(alpha: 0.6),
builder:
(_) => RoomCpProgressDialog(
leftUser: leftUser,
rightUser: rightUser,
daysValue: daysValue,
daysLabel: daysLabel,
levelText: levelText,
currentValue: currentValue,
targetValue: targetValue,
expAwayText: expAwayText,
nextLevelText: nextLevelText,
cancelText: cancelText,
dismissTag: dismissTag,
onCancel: onCancel,
),
);
}
double get _progressFraction {
if (targetValue <= 0) {
return 0;
}
return (currentValue / targetValue).clamp(0.0, 1.0).toDouble();
}
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Center(
child: Transform.translate(
offset: Offset(0, -42.w),
child: SizedBox(
width: 375.w,
height: 275.w,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 18.w,
top: 0,
child: Image.asset(
'$_roomCpProgressAssetBase/sc_cp_progress_dialog_bg.png',
width: 340.w,
height: 275.w,
fit: BoxFit.fill,
),
),
Positioned(
left: 131.w,
top: 46.w,
child: _DaysBadge(
daysValue: daysValue,
daysLabel: daysLabel,
levelText: levelText,
),
),
Positioned(
left: 71.w,
top: 51.w,
child: _ProgressAvatar(user: leftUser),
),
Positioned(
left: 246.w,
top: 51.w,
child: _ProgressAvatar(user: rightUser),
),
Positioned(
left: 60.w,
top: 122.w,
child: _UserName(user: leftUser),
),
Positioned(
left: 236.w,
top: 122.w,
child: _UserName(user: rightUser),
),
Positioned(
left: 58.w,
top: 146.w,
child: _ProgressBar(fraction: _progressFraction),
),
Positioned(
left: 0,
right: 0,
top: 180.w,
child: _ExpAwayText(
expAwayText: expAwayText,
nextLevelText: nextLevelText,
),
),
Positioned(
left: 130.w,
top: 206.w,
child: SCDebounceWidget(
onTap: () {
onCancel?.call();
SmartDialog.dismiss(tag: dismissTag);
},
child: _CancelButton(text: cancelText),
),
),
],
),
),
),
),
);
}
}
class _DaysBadge extends StatelessWidget {
const _DaysBadge({
required this.daysValue,
required this.daysLabel,
required this.levelText,
});
final String daysValue;
final String daysLabel;
final String levelText;
@override
Widget build(BuildContext context) {
final value = daysValue.trim().isEmpty ? '--' : daysValue.trim();
final label = daysLabel.trim().isEmpty ? 'Days' : daysLabel.trim();
final level =
levelText.trim().isEmpty ? 'Lv.1 Simple Love' : levelText.trim();
return SizedBox(
width: 114.w,
height: 87.w,
child: Stack(
alignment: Alignment.topCenter,
children: [
Image.asset(
'$_roomCpProgressAssetBase/sc_cp_progress_header.png',
width: 114.w,
height: 87.w,
fit: BoxFit.fill,
),
Positioned(
top: 18.w,
child: SizedBox(
width: 68.w,
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xffFEFEFF),
fontSize: 16.sp,
fontWeight: FontWeight.w700,
height: 1,
decoration: TextDecoration.none,
),
),
),
),
Positioned(
top: 39.w,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xffFEFEFF),
fontSize: 8.sp,
fontWeight: FontWeight.w400,
height: 1,
decoration: TextDecoration.none,
),
),
),
Positioned(
top: 65.w,
child: SizedBox(
width: 82.w,
child: Text(
level,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xffFEFEFF),
fontSize: 8.sp,
fontWeight: FontWeight.w400,
height: 1,
decoration: TextDecoration.none,
),
),
),
),
],
),
);
}
}
class _ProgressAvatar extends StatelessWidget {
const _ProgressAvatar({required this.user});
final RoomCpProgressDialogUser user;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 59.w,
height: 69.w,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
Positioned(left: 2.w, top: 5.w, child: _AvatarImage(user: user)),
Image.asset(
'$_roomCpProgressAssetBase/sc_cp_progress_avatar_frame.png',
width: 59.w,
height: 69.w,
fit: BoxFit.fill,
),
],
),
);
}
}
class _AvatarImage extends StatelessWidget {
const _AvatarImage({required this.user});
final RoomCpProgressDialogUser user;
@override
Widget build(BuildContext context) {
if (user.avatarUrl.trim().isNotEmpty) {
return ClipOval(
child: netImage(
url: user.avatarUrl,
width: 55.w,
height: 55.w,
fit: BoxFit.cover,
defaultImg: 'sc_images/general/sc_icon_avar_defalt.png',
),
);
}
return ClipOval(
child: Image.asset(
'sc_images/general/sc_icon_avar_defalt.png',
width: 55.w,
height: 55.w,
fit: BoxFit.cover,
),
);
}
}
class _UserName extends StatelessWidget {
const _UserName({required this.user});
final RoomCpProgressDialogUser user;
@override
Widget build(BuildContext context) {
final name =
user.nickname.trim().isEmpty ? 'NameName' : user.nickname.trim();
return SizedBox(
width: 80.w,
height: 12.w,
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xff543356),
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
decoration: TextDecoration.none,
),
),
);
}
}
class _ProgressBar extends StatelessWidget {
const _ProgressBar({required this.fraction});
final double fraction;
@override
Widget build(BuildContext context) {
final safeFraction = math.max(0.0, math.min(1.0, fraction));
return SizedBox(
width: 260.w,
height: 26.w,
child: Stack(
children: [
Image.asset(
'$_roomCpProgressAssetBase/sc_cp_progress_bar_bg.png',
width: 260.w,
height: 26.w,
fit: BoxFit.fill,
),
Positioned(
left: 9.w,
top: 5.w,
child: ClipRect(
child: Align(
alignment: Alignment.centerLeft,
widthFactor: safeFraction,
child: Image.asset(
'$_roomCpProgressAssetBase/sc_cp_progress_bar_fill.png',
width: 242.w,
height: 16.w,
fit: BoxFit.fill,
),
),
),
),
],
),
);
}
}
class _ExpAwayText extends StatelessWidget {
const _ExpAwayText({required this.expAwayText, required this.nextLevelText});
final String expAwayText;
final String nextLevelText;
@override
Widget build(BuildContext context) {
final exp = expAwayText.trim().isEmpty ? '1,000' : expAwayText.trim();
final nextLevel =
nextLevelText.trim().isEmpty ? 'Lv. 2' : nextLevelText.trim();
return Text.rich(
TextSpan(
children: [
TextSpan(
text: '$exp ',
style: const TextStyle(color: Color(0xffFF2A70)),
),
const TextSpan(
text: 'EXP away from ',
style: TextStyle(color: Color(0xff67576F)),
),
TextSpan(
text: nextLevel,
style: const TextStyle(color: Color(0xffFF2A70)),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
decoration: TextDecoration.none,
),
);
}
}
class _CancelButton extends StatelessWidget {
const _CancelButton({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 115.w,
height: 36.w,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
'$_roomCpProgressAssetBase/sc_cp_progress_cancel_btn.png',
width: 115.w,
height: 36.w,
fit: BoxFit.fill,
),
Text(
text.trim().isEmpty ? 'Cancel' : text.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w400,
height: 1,
decoration: TextDecoration.none,
),
),
],
),
);
}
}

View File

@ -1,288 +1,288 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_debouncer/flutter_debouncer.dart'; import 'package:flutter_debouncer/flutter_debouncer.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/main.dart'; import 'package:yumi/main.dart';
import 'package:marquee/marquee.dart'; import 'package:marquee/marquee.dart';
import 'package:yumi/app_localizations.dart'; import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
class FloatingRoomRocketScreenWidget extends StatefulWidget { class FloatingRoomRocketScreenWidget extends StatefulWidget {
final SCFloatingMessage message; final SCFloatingMessage message;
final VoidCallback onAnimationCompleted; // final VoidCallback onAnimationCompleted; //
const FloatingRoomRocketScreenWidget({ const FloatingRoomRocketScreenWidget({
Key? key, Key? key,
required this.message, required this.message,
required this.onAnimationCompleted, required this.onAnimationCompleted,
}) : super(key: key); }) : super(key: key);
@override @override
_FloatingRoomRocketScreenWidgetState createState() => _FloatingRoomRocketScreenWidgetState createState() =>
_FloatingRoomRocketScreenWidgetState(); _FloatingRoomRocketScreenWidgetState();
} }
class _FloatingRoomRocketScreenWidgetState class _FloatingRoomRocketScreenWidgetState
extends State<FloatingRoomRocketScreenWidget> extends State<FloatingRoomRocketScreenWidget>
with TickerProviderStateMixin { with TickerProviderStateMixin {
Debouncer debouncer = Debouncer(); Debouncer debouncer = Debouncer();
late AnimationController _controller; late AnimationController _controller;
late Animation<Offset> _firstAnimation; // late Animation<Offset> _firstAnimation; //
late Animation<Offset> _secondAnimation; // late Animation<Offset> _secondAnimation; //
late AnimationController _swipeController; // late AnimationController _swipeController; //
late Animation<Offset> _swipeAnimation; // late Animation<Offset> _swipeAnimation; //
bool _firstPhaseCompleted = false; bool _firstPhaseCompleted = false;
bool _isSwipeAnimating = false; // bool _isSwipeAnimating = false; //
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// //
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 1500), // 1.5 duration: const Duration(milliseconds: 1500), // 1.5
vsync: this, vsync: this,
); );
// //
_swipeController = AnimationController( _swipeController = AnimationController(
duration: const Duration(milliseconds: 550), // 500ms duration: const Duration(milliseconds: 550), // 500ms
vsync: this, vsync: this,
); );
// //
_swipeController.addStatusListener((status) { _swipeController.addStatusListener((status) {
if (status == AnimationStatus.completed) { if (status == AnimationStatus.completed) {
widget.onAnimationCompleted(); widget.onAnimationCompleted();
} }
}); });
// //
_firstAnimation = Tween<Offset>( _firstAnimation = Tween<Offset>(
begin: const Offset(1.5, 0.0), // begin: const Offset(1.5, 0.0), //
end: Offset.zero, // end: Offset.zero, //
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));
// //
_secondAnimation = Tween<Offset>( _secondAnimation = Tween<Offset>(
begin: Offset.zero, // begin: Offset.zero, //
end: const Offset(-1.5, 0.0), // end: const Offset(-1.5, 0.0), //
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));
// //
_swipeAnimation = Tween<Offset>( _swipeAnimation = Tween<Offset>(
begin: Offset.zero, // begin: Offset.zero, //
end: const Offset(-1.5, 0.0), // end: const Offset(-1.5, 0.0), //
).animate(CurvedAnimation( ).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn));
parent: _swipeController,
curve: Curves.easeIn, //
)); WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
// _startAnimationSequence();
WidgetsBinding.instance.addPostFrameCallback((_) { }
if (mounted) { });
_startAnimationSequence(); }
}
}); void _startAnimationSequence() async {
} //
void _startAnimationSequence() async { _controller.reset();
// await _controller.forward();
_controller.reset();
await _controller.forward(); await Future.delayed(_holdDuration);
// 23 if (!mounted) return;
await Future.delayed(const Duration(seconds: 3));
//
if (!mounted) return; setState(() {
_firstPhaseCompleted = true;
// });
setState(() { _controller.reset();
_firstPhaseCompleted = true; await _controller.forward();
});
_controller.reset(); if (mounted && !_isSwipeAnimating) {
await _controller.forward(); widget.onAnimationCompleted();
}
if (mounted && !_isSwipeAnimating) { }
widget.onAnimationCompleted();
} Duration get _holdDuration {
} final totalSeconds = widget.message.durationSeconds;
final totalMs = totalSeconds == null ? 6000 : (totalSeconds * 1000).round();
final holdMs = totalMs - 3000;
@override return Duration(milliseconds: holdMs.clamp(0, 10000).toInt());
void dispose() { }
_controller.dispose();
_swipeController.dispose(); @override
super.dispose(); void dispose() {
} _controller.dispose();
_swipeController.dispose();
@override super.dispose();
Widget build(BuildContext context) { }
// 使
if (_isSwipeAnimating) { @override
return SlideTransition( Widget build(BuildContext context) {
position: _swipeAnimation, // 使
child: _buildRocketAnimation(), if (_isSwipeAnimating) {
); return SlideTransition(
} position: _swipeAnimation,
child: _buildRocketAnimation(),
// 使 );
return SlideTransition( }
position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation,
child: _buildRocketAnimation(), // 使
); return SlideTransition(
} position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation,
child: _buildRocketAnimation(),
Widget _buildRocketAnimation() { );
// 使Positioned.fill确保飘屏覆盖全屏宽度100 }
return GestureDetector(
onTap: () { Widget _buildRocketAnimation() {
debouncer.debounce( // 使Positioned.fill确保飘屏覆盖全屏宽度100
duration: Duration(milliseconds: 350), return GestureDetector(
onDebounce: () { onTap: () {
if (widget.message.roomId != null && debouncer.debounce(
widget.message.roomId!.isNotEmpty) { duration: Duration(milliseconds: 350),
SCRoomUtils.goRoom( onDebounce: () {
widget.message.roomId!, if (widget.message.roomId != null &&
navigatorKey.currentState!.context, widget.message.roomId!.isNotEmpty) {
fromFloting: true SCRoomUtils.goRoom(
); widget.message.roomId!,
} navigatorKey.currentState!.context,
}, fromFloting: true,
); );
}, }
onHorizontalDragEnd: (details) { },
// );
final textDirection = Directionality.of(context); },
onHorizontalDragEnd: (details) {
// //
double effectiveVelocity(double velocity) { final textDirection = Directionality.of(context);
// RTL模式下
return textDirection == TextDirection.rtl //
? -velocity double effectiveVelocity(double velocity) {
: velocity; // RTL模式下
} return textDirection == TextDirection.rtl ? -velocity : velocity;
}
double velocity = effectiveVelocity(details.primaryVelocity ?? 0);
if (velocity < 0) { double velocity = effectiveVelocity(details.primaryVelocity ?? 0);
/// if (velocity < 0) {
_handleSwipeLeft(); ///
} _handleSwipeLeft();
}, }
child: Align( },
alignment: Alignment.topCenter, child: Align(
child: Directionality( alignment: Alignment.topCenter,
textDirection: TextDirection.ltr, child: Directionality(
child: Container( textDirection: TextDirection.ltr,
alignment: Alignment.topCenter, child: Container(
height: 128.w, alignment: Alignment.topCenter,
width: 290.w, height: 128.w,
margin: EdgeInsets.only(top: 10.w), width: 290.w,
padding: EdgeInsetsDirectional.only(start: 3.w, end: 25.w), margin: EdgeInsets.only(top: 10.w),
decoration: BoxDecoration( padding: EdgeInsetsDirectional.only(start: 3.w, end: 25.w),
image: DecorationImage( decoration: BoxDecoration(
image: AssetImage( image: DecorationImage(
"sc_images/index/sc_icon_rocket_broad_bg_lv${widget.message.rocketLevel}.webp", image: AssetImage(
), "sc_images/index/sc_icon_rocket_broad_bg_lv${widget.message.rocketLevel}.webp",
fit: BoxFit.fill, ),
), fit: BoxFit.fill,
), ),
child: Row( ),
mainAxisSize: MainAxisSize.min, // child: Row(
children: [ mainAxisSize: MainAxisSize.min, //
SizedBox(width: 5.w), children: [
Container( SizedBox(width: 5.w),
child: head( Container(
url: widget.message.userAvatarUrl ?? "", child: head(
width: 66.w, url: widget.message.userAvatarUrl ?? "",
), width: 66.w,
), ),
SizedBox(width: 4.w), ),
Expanded( SizedBox(width: 4.w),
child: Row( Expanded(
mainAxisAlignment: MainAxisAlignment.start, child: Row(
children: [ mainAxisAlignment: MainAxisAlignment.start,
Column( children: [
mainAxisSize: MainAxisSize.min, Column(
crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ mainAxisAlignment: MainAxisAlignment.center,
SizedBox(height: 6.w), children: [
Row( SizedBox(height: 6.w),
children: [ Row(
SizedBox( children: [
width: 60.w, SizedBox(
height: 22.w, width: 60.w,
child: Marquee( height: 22.w,
text: "ID:${widget.message.userId} ", child: Marquee(
style: TextStyle( text: "ID:${widget.message.userId} ",
fontSize: 15.sp, style: TextStyle(
color: Colors.yellow, fontSize: 15.sp,
fontStyle: FontStyle.italic, color: Colors.yellow,
fontWeight: FontWeight.w900, fontStyle: FontStyle.italic,
decoration: TextDecoration.none, fontWeight: FontWeight.w900,
), decoration: TextDecoration.none,
scrollAxis: Axis.horizontal, ),
crossAxisAlignment: scrollAxis: Axis.horizontal,
CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
blankSpace: 20.0, blankSpace: 20.0,
velocity: 40.0, velocity: 40.0,
pauseAfterRound: Duration(seconds: 1), pauseAfterRound: Duration(seconds: 1),
accelerationDuration: Duration(seconds: 1), accelerationDuration: Duration(seconds: 1),
accelerationCurve: Curves.easeOut, accelerationCurve: Curves.easeOut,
decelerationDuration: Duration( decelerationDuration: Duration(
milliseconds: 500, milliseconds: 500,
), ),
decelerationCurve: Curves.easeOut, decelerationCurve: Curves.easeOut,
), ),
), ),
text( text(
SCAppLocalizations.of(context)!.launchedARocket, SCAppLocalizations.of(context)!.launchedARocket,
textColor: Colors.white, textColor: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
fontSize: 13.sp, fontSize: 13.sp,
), ),
], ],
), ),
Transform.translate( Transform.translate(
offset: Offset(0, -5), offset: Offset(0, -5),
child: text( child: text(
SCAppLocalizations.of(context)!.letGoToWatch, SCAppLocalizations.of(context)!.letGoToWatch,
textColor: Colors.white, textColor: Colors.white,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
fontSize: 17.sp, fontSize: 17.sp,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
), ),
], ],
), ),
], ],
), ),
), ),
], ],
), ),
), ),
), ),
), ),
); );
} }
// //
void _handleSwipeLeft() { void _handleSwipeLeft() {
if (_isSwipeAnimating) return; if (_isSwipeAnimating) return;
setState(() { setState(() {
_isSwipeAnimating = true; _isSwipeAnimating = true;
}); });
// //
_controller.stop(); _controller.stop();
// //
_swipeController.reset(); _swipeController.reset();
_swipeController.forward(); _swipeController.forward();
} }
} }

View File

@ -0,0 +1,192 @@
import 'package:yumi/app/constants/sc_global_config.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_rocket_status_res.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_record_dialog.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart';
class RoomRocketApiMapper {
const RoomRocketApiMapper._();
static List<RoomRocketLevelItem>? levelsFromStatus(
SCRoomRocketStatusRes? status,
) {
final levels = status?.levels;
if (levels == null || levels.isEmpty) {
return null;
}
final sorted = [...levels]..sort((a, b) {
final sortCompare = a.sort.compareTo(b.sort);
return sortCompare != 0 ? sortCompare : a.level.compareTo(b.level);
});
return sorted
.map(
(item) => RoomRocketLevelItem(
label: 'Lv.${item.level}',
imageUrl: item.rocketIconUrl.isEmpty ? null : item.rocketIconUrl,
enabled: item.enabled,
),
)
.toList(growable: false);
}
static List<RoomRocketRewardItem>? rewardsFromStatus(
SCRoomRocketStatusRes? status,
) {
final rewards = status?.rewardPreview?.all ?? const [];
if (rewards.isEmpty) {
return null;
}
return rewards.map(rewardItemFromPreview).toList(growable: false);
}
static RoomRocketRewardItem rewardItemFromPreview(
SCRoomRocketRewardRes reward,
) {
return RoomRocketRewardItem(
imageUrl: reward.rewardCover.isEmpty ? null : reward.rewardCover,
amount: reward.displayAmount,
);
}
static List<RoomRocketCrewMember>? crewFromStatus(
SCRoomRocketStatusRes? status,
) {
final kings = status?.rocketKings ?? const [];
if (kings.isEmpty) {
return const <RoomRocketCrewMember>[];
}
return kings
.take(3)
.map(
(item) => RoomRocketCrewMember(
imageUrl: item.userAvatar.isEmpty ? null : item.userAvatar,
),
)
.toList(growable: false);
}
static List<RoomRocketRewardItem> rewardItemsFromRecords(
List<SCRoomRocketRewardRecordRes> records,
) {
return records.map(_rewardItemFromRecord).toList(growable: false);
}
static List<RoomRocketRewardWinner> winnersFromRecords(
List<SCRoomRocketRewardRecordRes> records,
) {
final grouped = <String, List<SCRoomRocketRewardRecordRes>>{};
for (final record in records) {
final key = record.userId.isNotEmpty ? record.userId : record.launchNo;
grouped.putIfAbsent(key, () => []).add(record);
}
return grouped.entries
.map((entry) {
final first = entry.value.first;
return RoomRocketRewardWinner(
nickname: first.userId.isNotEmpty ? 'ID:${first.userId}' : 'You',
rewards: rewardItemsFromRecords(entry.value),
);
})
.toList(growable: false);
}
static List<RoomRocketRecordItem> recordItemsFromRecords(
List<SCRoomRocketRewardRecordRes> records,
) {
final grouped = <String, List<SCRoomRocketRewardRecordRes>>{};
for (final record in records) {
final key = [
record.launchNo,
record.roomId,
record.roundNo,
record.grantTime,
record.createTime,
].join('|');
grouped.putIfAbsent(key, () => []).add(record);
}
return grouped.values
.map((items) {
final first = items.first;
return RoomRocketRecordItem(
roomId: 'RoomID:${first.roomId}',
levelLabel: _sceneLabel(first.rewardScene),
time: _formatRecordTime(first.grantTime, first.createTime),
rewards: rewardItemsFromRecords(items),
);
})
.toList(growable: false);
}
static List<RoomRocketNoteSection>? noteSectionsFromRule(dynamic rule) {
final text = _localizedRuleText(rule);
if (text.isEmpty) {
return null;
}
return [RoomRocketNoteSection(title: '', body: text)];
}
static RoomRocketRewardItem _rewardItemFromRecord(
SCRoomRocketRewardRecordRes record,
) {
return RoomRocketRewardItem(
imageUrl: record.rewardCover.isEmpty ? null : record.rewardCover,
amount: record.displayAmount,
);
}
static String _sceneLabel(String scene) {
switch (scene.toUpperCase()) {
case 'TOP1':
return 'Top1';
case 'IGNITE':
return 'Set off';
case 'IN_ROOM':
return 'In the room';
default:
return scene.isEmpty ? 'Reward' : scene;
}
}
static String _formatRecordTime(String grantTime, String createTime) {
final source = grantTime.isNotEmpty ? grantTime : createTime;
final parsed = DateTime.tryParse(source.replaceFirst(' ', 'T'));
if (parsed == null) {
return source;
}
String two(int value) => value.toString().padLeft(2, '0');
return '${two(parsed.month)}/${two(parsed.day)} ${two(parsed.hour)}:${two(parsed.minute)}';
}
static String _localizedRuleText(dynamic rule) {
if (rule is String) {
return rule.trim();
}
if (rule is! Map) {
return '';
}
final lang = SCGlobalConfig.lang;
final candidates = [
lang,
lang.replaceAll('_', '-'),
lang.split(RegExp('[-_]')).first,
'en',
'zh-CN',
];
for (final key in candidates) {
final value = rule[key];
final text = value?.toString().trim() ?? '';
if (text.isNotEmpty) {
return text;
}
}
for (final value in rule.values) {
final text = value?.toString().trim() ?? '';
if (text.isNotEmpty) {
return text;
}
}
return '';
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,450 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/services/audio/rtc_manager.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/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_record_dialog.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart';
class RoomRocketFloatingEntry extends StatelessWidget {
const RoomRocketFloatingEntry({super.key});
static const String dialogTag = 'showRoomRocketDialog';
static const String rewardDialogTag = 'showRoomRocketRewardDialog';
static const String recordDialogTag = 'showRoomRocketRecordDialog';
static const String noteDialogTag = 'showRoomRocketNoteDialog';
@override
Widget build(BuildContext context) {
return Selector<RtcProvider, SCRoomRocketStatusRes?>(
selector: (_, provider) => provider.roomRocketStatus,
builder: (context, status, child) {
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,
),
),
],
),
),
),
),
);
},
);
}
void _showRocketDialog(BuildContext context, SCRoomRocketStatusRes? status) {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final roomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
final navigator = Navigator.of(context, rootNavigator: true);
_rocketUiDebug(
'tap rocket entry roomId=$roomId hasStatus=${status != null}',
);
SmartDialog.show(
tag: dialogTag,
alignment: Alignment.center,
debounce: true,
animationType: SmartAnimationType.fade,
maskColor: Colors.transparent,
clickMaskDismiss: false,
builder:
(_) => _RoomRocketDialogLoader(
roomId: roomId,
navigator: navigator,
initialStatus: status,
onClose: () => SmartDialog.dismiss(tag: dialogTag),
),
);
}
}
class _RoomRocketDialogLoader extends StatefulWidget {
const _RoomRocketDialogLoader({
required this.roomId,
required this.navigator,
this.initialStatus,
this.onClose,
});
final String roomId;
final NavigatorState navigator;
final SCRoomRocketStatusRes? initialStatus;
final VoidCallback? onClose;
@override
State<_RoomRocketDialogLoader> createState() =>
_RoomRocketDialogLoaderState();
}
class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
SCRoomRocketStatusRes? _status;
List<RoomRocketNoteSection>? _noteSections;
@override
void initState() {
super.initState();
_status = widget.initialStatus;
unawaited(_load());
unawaited(_loadRule());
}
Future<void> _load() async {
if (widget.roomId.trim().isEmpty) {
_rocketUiDebug('skip status request because roomId is empty');
return;
}
try {
_rocketUiDebug('request status from main dialog roomId=${widget.roomId}');
final res = await SCChatRoomRepository().rocketStatus(widget.roomId);
if (!mounted) {
return;
}
setState(() => _status = res);
try {
Provider.of<RtcProvider>(
context,
listen: false,
).updateRoomRocketConfigurationStatus(res);
} catch (_) {}
} catch (error) {
_rocketUiDebug('status request failed: $error');
}
}
Future<void> _loadRule() async {
try {
_rocketUiDebug('prefetch rule from main dialog');
final rule = await SCChatRoomRepository().roomRocketRule();
if (!mounted) {
return;
}
setState(() {
_noteSections = RoomRocketApiMapper.noteSectionsFromRule(rule);
});
} catch (error) {
_rocketUiDebug('prefetch rule failed: $error');
}
}
@override
Widget build(BuildContext context) {
final status = _status;
final selectedLevel =
((status?.currentLevel ?? status?.level ?? 1) - 1).clamp(0, 5).toInt();
return RoomRocketDialog(
status: status,
levels: RoomRocketApiMapper.levelsFromStatus(status),
selectedLevel: selectedLevel,
rewards: RoomRocketApiMapper.rewardsFromStatus(status),
crew: RoomRocketApiMapper.crewFromStatus(status),
onClose: widget.onClose,
onOpenRank: () => _openRank(selectedLevel),
onRewardSelected: _openReward,
onOpenRecord: _openRecord,
onOpenNote: _openNote,
);
}
void _openRank(int selectedLevel) {
_rocketUiDebug(
'tap rocket king roomId=${widget.roomId} level=${selectedLevel + 1}',
);
SmartDialog.dismiss(tag: RoomRocketFloatingEntry.dialogTag).then((_) {
if (!widget.navigator.mounted) {
_rocketUiDebug('skip rocket king push because navigator is unmounted');
return;
}
_rocketUiDebug(
'push rocket king roomId=${widget.roomId} level=${selectedLevel + 1}',
);
VoiceRoomRoute.openRoomRocketRankOn(
widget.navigator,
roomId: widget.roomId,
initialLevel: selectedLevel,
);
});
}
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((_) {
SmartDialog.show(
tag: RoomRocketFloatingEntry.recordDialogTag,
alignment: Alignment.center,
debounce: true,
animationType: SmartAnimationType.fade,
maskColor: Colors.transparent,
clickMaskDismiss: false,
builder:
(_) => _RoomRocketRecordDialogLoader(
roomId: widget.roomId,
onClose:
() => SmartDialog.dismiss(
tag: RoomRocketFloatingEntry.recordDialogTag,
),
),
);
});
}
void _openNote() {
_rocketUiDebug('tap note roomId=${widget.roomId}');
SmartDialog.dismiss(tag: RoomRocketFloatingEntry.dialogTag).then((_) {
SmartDialog.show(
tag: RoomRocketFloatingEntry.noteDialogTag,
alignment: Alignment.center,
debounce: true,
animationType: SmartAnimationType.fade,
maskColor: Colors.transparent,
clickMaskDismiss: false,
builder:
(_) => _RoomRocketNoteDialogLoader(
initialSections: _noteSections,
onClose:
() => SmartDialog.dismiss(
tag: RoomRocketFloatingEntry.noteDialogTag,
),
),
);
});
}
}
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});
final String roomId;
final VoidCallback? onClose;
@override
State<_RoomRocketRecordDialogLoader> createState() =>
_RoomRocketRecordDialogLoaderState();
}
class _RoomRocketRecordDialogLoaderState
extends State<_RoomRocketRecordDialogLoader> {
List<RoomRocketRecordItem>? _records;
bool _loading = true;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
try {
_rocketUiDebug('request reward-records roomId=${widget.roomId}');
final res = await SCChatRoomRepository().roomRocketRewardRecords(
widget.roomId,
);
if (!mounted) {
return;
}
setState(() {
_records = RoomRocketApiMapper.recordItemsFromRecords(res.records);
_loading = false;
});
} catch (error) {
_rocketUiDebug('reward-records request failed: $error');
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
return RoomRocketRecordDialog(
records: _records,
useMockFallback: false,
loadingText: _loading ? 'Loading data......' : '',
limitText: 'Show only records from the last 35 days',
onClose: widget.onClose,
);
}
}
class _RoomRocketNoteDialogLoader extends StatefulWidget {
const _RoomRocketNoteDialogLoader({this.initialSections, this.onClose});
final List<RoomRocketNoteSection>? initialSections;
final VoidCallback? onClose;
@override
State<_RoomRocketNoteDialogLoader> createState() =>
_RoomRocketNoteDialogLoaderState();
}
class _RoomRocketNoteDialogLoaderState
extends State<_RoomRocketNoteDialogLoader> {
List<RoomRocketNoteSection>? _sections;
@override
void initState() {
super.initState();
_sections = widget.initialSections;
if (_sections != null) {
return;
}
unawaited(_load());
}
Future<void> _load() async {
try {
_rocketUiDebug('request rule');
final rule = await SCChatRoomRepository().roomRocketRule();
if (!mounted) {
return;
}
setState(() {
_sections = RoomRocketApiMapper.noteSectionsFromRule(rule);
});
} catch (error) {
_rocketUiDebug('rule request failed: $error');
}
}
@override
Widget build(BuildContext context) {
return RoomRocketNoteDialog(sections: _sections, onClose: widget.onClose);
}
}
void _rocketUiDebug(String message) {
if (kDebugMode) {
debugPrint('[RoomRocketUi] $message');
}
}

View File

@ -0,0 +1,389 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:yumi/app_localizations.dart';
class RoomRocketNoteDialog extends StatelessWidget {
const RoomRocketNoteDialog({super.key, this.sections, this.onClose});
final List<RoomRocketNoteSection>? sections;
final VoidCallback? onClose;
@override
Widget build(BuildContext context) {
final localizations = SCAppLocalizations.of(context);
final title = _translate(localizations, 'roomRocketNoteTitle', 'Note');
final resolvedSections = _normalizeSections(localizations, sections);
final contentDirection = Directionality.of(context);
return Directionality(
textDirection: TextDirection.ltr,
child: Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Container(color: Colors.black.withValues(alpha: 0.60)),
),
),
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final scale = math.min(
constraints.maxWidth / _designWidth,
constraints.maxHeight / _designHeight,
);
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
width: _designWidth * scale,
height: _designHeight * scale,
child: _NoteDialogCanvas(
scale: scale,
title: title,
sections: resolvedSections,
contentDirection: contentDirection,
onClose: onClose,
),
),
);
},
),
),
],
),
),
);
}
static List<RoomRocketNoteSection> _normalizeSections(
SCAppLocalizations? localizations,
List<RoomRocketNoteSection>? sections,
) {
final source =
sections
?.where(
(item) =>
item.title.trim().isNotEmpty || item.body.trim().isNotEmpty,
)
.toList(growable: false) ??
const <RoomRocketNoteSection>[];
if (source.isNotEmpty) {
return source;
}
final title = _translate(
localizations,
'roomRocketNoteSectionTitle',
'Each time a VIP',
);
final body = _translate(
localizations,
'roomRocketNoteSectionBody',
'1. Each time a VIP level is purchased, it is valid for 30 days, and can be manually renewed after expiration.',
);
return List<RoomRocketNoteSection>.generate(
5,
(_) => RoomRocketNoteSection(title: title, body: body),
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 RoomRocketNoteSection {
const RoomRocketNoteSection({required this.title, required this.body});
final String title;
final String body;
}
class _NoteDialogCanvas extends StatelessWidget {
const _NoteDialogCanvas({
required this.scale,
required this.title,
required this.sections,
required this.contentDirection,
this.onClose,
});
final double scale;
final String title;
final List<RoomRocketNoteSection> sections;
final TextDirection contentDirection;
final VoidCallback? onClose;
double s(double value) => value * scale;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.hardEdge,
children: [
_buildPanelBackground(),
_buildHeader(),
_buildContent(),
_buildCloseButton(),
],
);
}
Widget _buildPanelBackground() {
return Stack(
children: [
_image(_NoteDialogAssets.panelTop, 8, 176, 360, 63),
for (final top in const [
239.0,
298.0,
357.0,
416.0,
475.0,
534.0,
593.0,
652.0,
])
_image(_NoteDialogAssets.panelMiddle, 8, top, 360, 59),
_image(_NoteDialogAssets.panelBottom, 8, 673, 360, 76),
],
);
}
Widget _buildHeader() {
return Stack(
children: [
Positioned(
left: 0,
top: s(196),
width: s(_designWidth),
child: _GradientText(
title,
fontSize: s(16),
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
),
Positioned(
left: s(28),
top: s(220),
width: s(320),
height: math.max(1, s(1)),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFB2FFE7).withValues(alpha: 0.16),
),
),
),
],
);
}
Widget _buildContent() {
final isRtl = contentDirection == TextDirection.rtl;
return Positioned(
left: s(35),
top: s(254),
width: s(305),
height: s(448),
child: Directionality(
textDirection: contentDirection,
child: ScrollConfiguration(
behavior: const _NoGlowScrollBehavior(),
child: ListView.separated(
padding: EdgeInsets.zero,
primary: false,
physics: const BouncingScrollPhysics(),
itemCount: sections.length,
separatorBuilder: (_, __) => SizedBox(height: s(12)),
itemBuilder: (context, index) {
final item = sections[index];
return _NoteSectionView(
scale: scale,
section: item,
textAlign: isRtl ? TextAlign.right : TextAlign.left,
);
},
),
),
),
);
}
Widget _buildCloseButton() {
return Positioned(
left: s(326),
top: s(182),
width: s(32),
height: s(32),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF75F3CB).withValues(alpha: 0.72),
width: s(1),
),
),
child: SizedBox(
width: s(18),
height: s(18),
child: Icon(
Icons.close_rounded,
size: s(15),
color: const Color(0xFF75F3CB).withValues(alpha: 0.86),
),
),
),
),
),
);
}
Widget _image(
String asset,
double left,
double top,
double width,
double height, {
BoxFit fit = BoxFit.fill,
}) {
return Positioned(
left: s(left),
top: s(top),
width: s(width),
height: s(height),
child: Image.asset(asset, fit: fit, gaplessPlayback: true),
);
}
}
class _NoteSectionView extends StatelessWidget {
const _NoteSectionView({
required this.scale,
required this.section,
required this.textAlign,
});
final double scale;
final RoomRocketNoteSection section;
final TextAlign textAlign;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment:
textAlign == TextAlign.right
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Text(
section.title,
textScaler: TextScaler.noScaling,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
style: TextStyle(
color: const Color(0xFF75F3CB),
fontSize: 16 * scale,
height: 18 / 16,
decoration: TextDecoration.none,
),
),
SizedBox(height: 8 * scale),
Text(
section.body,
textScaler: TextScaler.noScaling,
textAlign: textAlign,
style: TextStyle(
color: Colors.white,
fontSize: 16 * scale,
height: 18 / 16,
decoration: TextDecoration.none,
),
),
],
);
}
}
class _GradientText extends StatelessWidget {
const _GradientText(
this.text, {
required this.fontSize,
this.fontWeight = FontWeight.w400,
this.textAlign = TextAlign.left,
});
final String text;
final double fontSize;
final FontWeight fontWeight;
final TextAlign textAlign;
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback:
(bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Colors.white, Color(0xFFA4FFE3)],
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
textAlign: textAlign,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1,
fontWeight: fontWeight,
decoration: TextDecoration.none,
),
),
);
}
}
class _NoGlowScrollBehavior extends ScrollBehavior {
const _NoGlowScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
}
class _NoteDialogAssets {
const _NoteDialogAssets._();
static const String panelTop = 'sc_images/room/rocket/reward_dialog_top.png';
static const String panelMiddle =
'sc_images/room/rocket/reward_dialog_middle.png';
static const String panelBottom =
'sc_images/room/rocket/reward_dialog_bottom.png';
}
const double _designWidth = 375;
const double _designHeight = 812;

View File

@ -0,0 +1,687 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:yumi/ui_kit/components/custom_cached_image.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart';
class RoomRocketRecordDialog extends StatelessWidget {
const RoomRocketRecordDialog({
super.key,
this.records,
this.loadingText = 'Loading data......',
this.limitText = 'Show only records from the last 35 days',
this.useMockFallback = true,
this.onClose,
});
final List<RoomRocketRecordItem>? records;
final String loadingText;
final String limitText;
final bool useMockFallback;
final VoidCallback? onClose;
@override
Widget build(BuildContext context) {
final resolvedRecords = _normalizeRecords(
records,
useMockFallback: useMockFallback,
);
return Directionality(
textDirection: TextDirection.ltr,
child: Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Container(color: Colors.black.withValues(alpha: 0.60)),
),
),
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final scale = math.min(
constraints.maxWidth / _designWidth,
constraints.maxHeight / _designHeight,
);
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
width: _designWidth * scale,
height: _designHeight * scale,
child: _RecordDialogCanvas(
scale: scale,
records: resolvedRecords,
loadingText: loadingText,
limitText: limitText,
onClose: onClose,
),
),
);
},
),
),
],
),
),
);
}
static List<RoomRocketRecordItem> _normalizeRecords(
List<RoomRocketRecordItem>? records, {
required bool useMockFallback,
}) {
final source =
records
?.where((item) => item.roomId.trim().isNotEmpty)
.toList(growable: false) ??
const <RoomRocketRecordItem>[];
if (source.isNotEmpty) {
return source;
}
if (!useMockFallback) {
return const <RoomRocketRecordItem>[];
}
const reward = RoomRocketRewardItem(
asset: _RecordDialogAssets.rewardThumb,
amount: '13.2k',
);
return List<RoomRocketRecordItem>.generate(
7,
(_) => const RoomRocketRecordItem(
roomId: 'RoomID:1235645',
levelLabel: 'Top1',
time: '31/01 14:14',
rewards: [reward, reward, reward, reward],
),
growable: false,
);
}
}
class RoomRocketRecordItem {
const RoomRocketRecordItem({
required this.roomId,
required this.levelLabel,
required this.time,
this.rewards = const <RoomRocketRewardItem>[],
});
final String roomId;
final String levelLabel;
final String time;
final List<RoomRocketRewardItem> rewards;
}
class _RecordDialogCanvas extends StatelessWidget {
const _RecordDialogCanvas({
required this.scale,
required this.records,
required this.loadingText,
required this.limitText,
this.onClose,
});
final double scale;
final List<RoomRocketRecordItem> records;
final String loadingText;
final String limitText;
final VoidCallback? onClose;
double s(double value) => value * scale;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.hardEdge,
children: [
_buildPanelBackground(),
_buildHeader(),
_buildColumnTitles(),
_buildRecordList(),
_buildFooter(),
_buildCloseButton(),
],
);
}
Widget _buildPanelBackground() {
return Stack(
children: [
_image(_RecordDialogAssets.panelTop, 8, 176, 360, 63),
for (final top in const [
239.0,
298.0,
357.0,
416.0,
475.0,
534.0,
593.0,
652.0,
])
_image(_RecordDialogAssets.panelMiddle, 8, top, 360, 59),
_image(_RecordDialogAssets.panelBottom, 8, 673, 360, 76),
],
);
}
Widget _buildHeader() {
return Stack(
children: [
Positioned(
left: 0,
top: s(196),
width: s(_designWidth),
child: _GradientText(
'Record',
fontSize: s(16),
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
),
Positioned(
left: s(28),
top: s(220),
width: s(320),
height: math.max(1, s(1)),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFB2FFE7).withValues(alpha: 0.16),
),
),
),
],
);
}
Widget _buildColumnTitles() {
return Stack(
children: [
Positioned(
left: s(66),
top: s(240),
width: s(79),
height: s(14),
child: _SectionTitle(text: 'Room', scale: scale),
),
Positioned(
left: s(219),
top: s(240),
width: s(90),
height: s(14),
child: _SectionTitle(text: 'Reward', scale: scale),
),
],
);
}
Widget _buildRecordList() {
return Positioned(
left: s(11),
top: s(268),
width: s(353),
height: s(419),
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]);
},
),
),
),
);
}
Widget _buildFooter() {
return Stack(
children: [
Positioned(
left: 0,
top: s(703),
width: s(_designWidth),
child: _FooterText(
text: loadingText,
scale: scale,
fontSize: 10,
color: const Color(0xFF78FF78).withValues(alpha: 0.56),
),
),
Positioned(
left: 0,
top: s(720),
width: s(_designWidth),
child: _FooterText(
text: limitText,
scale: scale,
fontSize: 12,
color: const Color(0xFF78FF78),
),
),
],
);
}
Widget _buildCloseButton() {
return Positioned(
left: s(326),
top: s(182),
width: s(32),
height: s(32),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF75F3CB).withValues(alpha: 0.72),
width: s(1),
),
),
child: SizedBox(
width: s(18),
height: s(18),
child: Icon(
Icons.close_rounded,
size: s(15),
color: const Color(0xFF75F3CB).withValues(alpha: 0.86),
),
),
),
),
),
);
}
Widget _image(
String asset,
double left,
double top,
double width,
double height, {
BoxFit fit = BoxFit.fill,
}) {
return Positioned(
left: s(left),
top: s(top),
width: s(width),
height: s(height),
child: Image.asset(asset, fit: fit, gaplessPlayback: true),
);
}
}
class _RecordRow extends StatelessWidget {
const _RecordRow({required this.scale, required this.item});
final double scale;
final RoomRocketRecordItem item;
@override
Widget build(BuildContext context) {
final rewards =
item.rewards.isNotEmpty
? item.rewards
: const [
RoomRocketRewardItem(
asset: _RecordDialogAssets.rewardThumb,
amount: '13.2k',
),
];
return SizedBox(
height: 61 * scale,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
height: math.max(1, scale),
child: const _RowDivider(),
),
Positioned(
left: 27 * scale,
top: 0,
width: 130 * scale,
child: _PlainText(
item.roomId,
scale: scale,
fontSize: 14,
color: Colors.white,
),
),
Positioned(
left: 27 * scale,
top: 18 * scale,
width: 90 * scale,
child: _PlainText(
item.levelLabel,
scale: scale,
fontSize: 12,
color: Colors.white,
),
),
Positioned(
left: 27 * scale,
top: 34 * scale,
width: 90 * scale,
child: _PlainText(
item.time,
scale: scale,
fontSize: 10,
color: Colors.white.withValues(alpha: 0.5),
),
),
Positioned(
left: 179 * scale,
top: 3 * scale,
width: 166 * scale,
height: 38 * scale,
child: ClipRect(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
children: [
for (var index = 0; index < rewards.length; index++) ...[
_SmallReward(scale: scale, item: rewards[index]),
if (index != rewards.length - 1)
SizedBox(width: 12 * scale),
],
],
),
),
),
),
],
),
);
}
}
class _SmallReward extends StatelessWidget {
const _SmallReward({required this.scale, required this.item});
final double scale;
final RoomRocketRewardItem item;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28 * scale,
height: 38 * scale,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
top: 0,
width: 28 * scale,
height: 28 * scale,
child: _RocketImage(
asset: item.asset ?? _RecordDialogAssets.rewardThumb,
imageUrl: item.imageUrl,
fit: BoxFit.cover,
),
),
Positioned(
left: 0,
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,
child: Text(
item.amount,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.visible,
style: TextStyle(
color: Colors.white,
fontSize: 8 * scale,
height: 1,
decoration: TextDecoration.none,
),
),
),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.text, required this.scale});
final String text;
final double scale;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_SlashGroup(scale: scale),
SizedBox(width: 4 * scale),
Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.visible,
style: TextStyle(
color: const Color(0xFF75F3CB),
fontSize: 14 * scale,
height: 1,
decoration: TextDecoration.none,
),
),
SizedBox(width: 4 * scale),
Transform.rotate(angle: math.pi, child: _SlashGroup(scale: scale)),
],
);
}
}
class _SlashGroup extends StatelessWidget {
const _SlashGroup({required this.scale});
final double scale;
@override
Widget build(BuildContext context) {
return Text(
'///',
textScaler: TextScaler.noScaling,
style: TextStyle(
color: const Color(0xFF75F3CB),
fontSize: 10 * scale,
height: 1,
letterSpacing: 0,
decoration: TextDecoration.none,
),
);
}
}
class _PlainText extends StatelessWidget {
const _PlainText(
this.text, {
required this.scale,
required this.fontSize,
required this.color,
});
final String text;
final double scale;
final double fontSize;
final Color color;
@override
Widget build(BuildContext context) {
return Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: fontSize * scale,
height: 1,
decoration: TextDecoration.none,
),
);
}
}
class _FooterText extends StatelessWidget {
const _FooterText({
required this.text,
required this.scale,
required this.fontSize,
required this.color,
});
final String text;
final double scale;
final double fontSize;
final Color color;
@override
Widget build(BuildContext context) {
return Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: color,
fontSize: fontSize * scale,
height: 1,
decoration: TextDecoration.none,
),
);
}
}
class _RocketImage extends StatelessWidget {
const _RocketImage({this.asset, this.imageUrl, this.fit = BoxFit.cover});
final String? asset;
final String? imageUrl;
final BoxFit fit;
@override
Widget build(BuildContext context) {
if (imageUrl != null && imageUrl!.trim().isNotEmpty) {
return CustomCachedImage(imageUrl: imageUrl!, fit: fit);
}
if (asset != null && asset!.trim().isNotEmpty) {
return Image.asset(asset!, fit: fit, gaplessPlayback: true);
}
return const SizedBox.shrink();
}
}
class _GradientText extends StatelessWidget {
const _GradientText(
this.text, {
required this.fontSize,
this.fontWeight = FontWeight.w400,
this.textAlign = TextAlign.left,
});
final String text;
final double fontSize;
final FontWeight fontWeight;
final TextAlign textAlign;
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback:
(bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Colors.white, Color(0xFFA4FFE3)],
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
textAlign: textAlign,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1,
fontWeight: fontWeight,
decoration: TextDecoration.none,
),
),
);
}
}
class _RowDivider extends StatelessWidget {
const _RowDivider();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0),
Colors.white.withValues(alpha: 0.16),
Colors.white.withValues(alpha: 0),
],
),
),
);
}
}
class _NoGlowScrollBehavior extends ScrollBehavior {
const _NoGlowScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
}
class _RecordDialogAssets {
const _RecordDialogAssets._();
static const String panelTop = 'sc_images/room/rocket/reward_dialog_top.png';
static const String panelMiddle =
'sc_images/room/rocket/reward_dialog_middle.png';
static const String panelBottom =
'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';
}
const double _designWidth = 375;
const double _designHeight = 812;

View File

@ -0,0 +1,927 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:yumi/ui_kit/components/custom_cached_image.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart';
class RoomRocketRewardDialog extends StatelessWidget {
const RoomRocketRewardDialog({
super.key,
this.selectedReward,
this.rewards,
this.winners,
this.loadingText = 'Loading data......',
this.useMockFallback = true,
this.onClose,
});
final RoomRocketRewardItem? selectedReward;
final List<RoomRocketRewardItem>? rewards;
final List<RoomRocketRewardWinner>? winners;
final String loadingText;
final bool useMockFallback;
final VoidCallback? onClose;
@override
Widget build(BuildContext context) {
final resolvedRewards = _normalizeRewards(
selectedReward,
rewards,
useMockFallback: useMockFallback,
);
final resolvedWinners = _normalizeWinners(
winners,
resolvedRewards,
useMockFallback: useMockFallback,
);
return Directionality(
textDirection: TextDirection.ltr,
child: Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Container(color: Colors.black.withValues(alpha: 0.60)),
),
),
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final scale = math.min(
constraints.maxWidth / _designWidth,
constraints.maxHeight / _designHeight,
);
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
width: _designWidth * scale,
height: _designHeight * scale,
child: _RewardDialogCanvas(
scale: scale,
rewards: resolvedRewards,
winners: resolvedWinners,
loadingText: loadingText,
onClose: onClose,
),
),
);
},
),
),
],
),
),
);
}
static List<RoomRocketRewardItem> _normalizeRewards(
RoomRocketRewardItem? selectedReward,
List<RoomRocketRewardItem>? rewards, {
required bool useMockFallback,
}) {
final source =
rewards?.where((item) => item.amount.trim().isNotEmpty).toList() ??
const <RoomRocketRewardItem>[];
if (source.isNotEmpty) {
return source;
}
if (!useMockFallback && selectedReward == null) {
return const <RoomRocketRewardItem>[];
}
final fallback =
selectedReward ??
const RoomRocketRewardItem(
asset: _RewardDialogAssets.rewardThumb,
amount: '10000',
);
return List<RoomRocketRewardItem>.generate(
6,
(_) => fallback,
growable: false,
);
}
static List<RoomRocketRewardWinner> _normalizeWinners(
List<RoomRocketRewardWinner>? winners,
List<RoomRocketRewardItem> rewards, {
required bool useMockFallback,
}) {
final source =
winners
?.where((item) => item.nickname.trim().isNotEmpty)
.toList(growable: false) ??
const <RoomRocketRewardWinner>[];
if (source.isNotEmpty) {
return source;
}
if (!useMockFallback || rewards.isEmpty) {
return const <RoomRocketRewardWinner>[];
}
return const [
RoomRocketRewardWinner(
nickname: 'namenamenam...',
frameAsset: _RewardDialogAssets.avatar1,
),
RoomRocketRewardWinner(
nickname: 'namenamenam...',
frameAsset: _RewardDialogAssets.avatar2,
),
RoomRocketRewardWinner(
nickname: 'namenamenam...',
frameAsset: _RewardDialogAssets.avatar3,
),
RoomRocketRewardWinner(
nickname: 'namenamenam...',
frameAsset: _RewardDialogAssets.avatar1,
),
].map((winner) => winner.withRewards(_winnerRewards(rewards))).toList();
}
static List<RoomRocketRewardItem> _winnerRewards(
List<RoomRocketRewardItem> rewards,
) {
return List<RoomRocketRewardItem>.generate(4, (index) {
final reward = rewards[index % rewards.length];
return RoomRocketRewardItem(
asset: reward.asset,
imageUrl: reward.imageUrl,
amount: '13.2k',
);
}, growable: false);
}
}
class RoomRocketRewardWinner {
const RoomRocketRewardWinner({
required this.nickname,
this.avatarAsset,
this.avatarUrl,
this.frameAsset,
this.rewards = const <RoomRocketRewardItem>[],
});
final String nickname;
final String? avatarAsset;
final String? avatarUrl;
final String? frameAsset;
final List<RoomRocketRewardItem> rewards;
RoomRocketRewardWinner withRewards(List<RoomRocketRewardItem> nextRewards) {
return RoomRocketRewardWinner(
nickname: nickname,
avatarAsset: avatarAsset,
avatarUrl: avatarUrl,
frameAsset: frameAsset,
rewards: nextRewards,
);
}
}
class _RewardDialogCanvas extends StatelessWidget {
const _RewardDialogCanvas({
required this.scale,
required this.rewards,
required this.winners,
required this.loadingText,
this.onClose,
});
final double scale;
final List<RoomRocketRewardItem> rewards;
final List<RoomRocketRewardWinner> winners;
final String loadingText;
final VoidCallback? onClose;
double s(double value) => value * scale;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.hardEdge,
children: [
_buildPanelBackground(),
_buildHeader(),
_buildRewardGrid(),
_buildWinnerList(),
_buildLoadingText(),
_buildCloseButton(),
],
);
}
Widget _buildPanelBackground() {
return Stack(
children: [
_image(_RewardDialogAssets.panelTop, 8, 176, 360, 63),
for (final top in const [
239.0,
298.0,
357.0,
416.0,
475.0,
534.0,
593.0,
652.0,
])
_image(_RewardDialogAssets.panelMiddle, 8, top, 360, 59),
_image(_RewardDialogAssets.panelBottom, 8, 673, 360, 76),
],
);
}
Widget _buildHeader() {
return Stack(
children: [
Positioned(
left: 0,
top: s(196),
width: s(_designWidth),
child: _GradientText(
'Reward',
fontSize: s(16),
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
),
Positioned(
left: s(28),
top: s(220),
width: s(320),
height: math.max(1, s(1)),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFB2FFE7).withValues(alpha: 0.16),
),
),
),
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),
),
],
);
}
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]),
),
],
);
}
Widget _buildWinnerList() {
return Positioned(
left: s(12),
top: s(507),
width: s(353),
height: s(198),
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,
);
},
),
),
),
);
}
Widget _buildLoadingText() {
return Positioned(
left: 0,
top: s(720),
width: s(_designWidth),
child: Text(
loadingText,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF78FF78).withValues(alpha: 0.56),
fontSize: s(10),
height: 1,
decoration: TextDecoration.none,
),
),
);
}
Widget _buildCloseButton() {
return Positioned(
left: s(326),
top: s(182),
width: s(32),
height: s(32),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF75F3CB).withValues(alpha: 0.72),
width: s(1),
),
),
child: SizedBox(
width: s(18),
height: s(18),
child: Icon(
Icons.close_rounded,
size: s(15),
color: const Color(0xFF75F3CB).withValues(alpha: 0.86),
),
),
),
),
),
);
}
Widget _image(
String asset,
double left,
double top,
double width,
double height, {
BoxFit fit = BoxFit.fill,
}) {
return Positioned(
left: s(left),
top: s(top),
width: s(width),
height: s(height),
child: Image.asset(asset, fit: fit, gaplessPlayback: true),
);
}
}
class _RewardDetailCard extends StatelessWidget {
const _RewardDetailCard({required this.scale, required this.item});
final double scale;
final RoomRocketRewardItem item;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: CustomPaint(painter: _RewardDetailCardPainter()),
),
Positioned(
left: 23 * scale,
top: 11 * scale,
width: 50 * scale,
height: 50 * scale,
child: _RocketImage(
asset: item.asset ?? _RewardDialogAssets.rewardThumb,
imageUrl: item.imageUrl,
fit: BoxFit.cover,
),
),
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,
top: 73 * scale,
width: 44 * scale,
child: Text(
item.amount,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12 * scale,
height: 1,
decoration: TextDecoration.none,
),
),
),
],
);
}
}
class _WinnerRow extends StatelessWidget {
const _WinnerRow({
required this.scale,
required this.winner,
required this.showRocketBadge,
});
final double scale;
final RoomRocketRewardWinner winner;
final bool showRocketBadge;
@override
Widget build(BuildContext context) {
final rewards = winner.rewards.take(4).toList(growable: false);
return SizedBox(
height: 51 * scale,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
height: math.max(1, scale),
child: const _RowDivider(),
),
Positioned(
left: 17 * scale,
top: 0,
width: 39 * scale,
height: 39 * scale,
child: _WinnerAvatar(scale: scale, winner: winner),
),
if (showRocketBadge)
Positioned(
left: 40 * scale,
top: 26 * scale,
width: 14 * scale,
height: 14 * scale,
child: ClipRRect(
borderRadius: BorderRadius.circular(3 * scale),
child: Transform.rotate(
angle: math.pi / 4,
child: Image.asset(
_RewardDialogAssets.rocketBody,
fit: BoxFit.cover,
),
),
),
),
Positioned(
left: 62 * scale,
top: 11 * scale,
width: 120 * scale,
child: Text(
winner.nickname,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14 * scale,
height: 1,
decoration: TextDecoration.none,
),
),
),
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]),
),
],
),
);
}
}
class _SmallReward extends StatelessWidget {
const _SmallReward({required this.scale, required this.item});
final double scale;
final RoomRocketRewardItem item;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 2 * scale,
top: 0,
width: 22 * scale,
height: 22 * scale,
child: _RocketImage(
asset: item.asset ?? _RewardDialogAssets.rewardThumb,
imageUrl: item.imageUrl,
fit: BoxFit.cover,
),
),
Positioned(
left: 0,
top: 25 * scale,
width: 6 * scale,
height: 6 * scale,
child: Image.asset(_RewardDialogAssets.coin, fit: BoxFit.contain),
),
Positioned(
left: 7 * scale,
top: 24 * scale,
width: 22 * scale,
child: Text(
_compactAmount(item.amount),
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 8 * scale,
height: 1,
decoration: TextDecoration.none,
),
),
),
],
);
}
static String _compactAmount(String amount) {
final value = num.tryParse(amount);
if (value == null || value < 10000) {
return amount;
}
final compact = value / 1000;
return '${compact.toStringAsFixed(compact.truncateToDouble() == compact ? 0 : 1)}k';
}
}
class _WinnerAvatar extends StatelessWidget {
const _WinnerAvatar({required this.scale, required this.winner});
final double scale;
final RoomRocketRewardWinner winner;
@override
Widget build(BuildContext context) {
if (winner.frameAsset == null) {
return ClipOval(
child: _WinnerAvatarImage(
asset: winner.avatarAsset,
imageUrl: winner.avatarUrl,
),
);
}
return Stack(
children: [
Positioned(
left: 5 * scale,
top: 4 * scale,
width: 30 * scale,
height: 30 * scale,
child: ClipOval(
child: _WinnerAvatarImage(
asset: winner.avatarAsset,
imageUrl: winner.avatarUrl,
),
),
),
Positioned.fill(
child: _RocketImage(asset: winner.frameAsset, fit: BoxFit.contain),
),
],
);
}
}
class _WinnerAvatarImage extends StatelessWidget {
const _WinnerAvatarImage({this.asset, this.imageUrl});
final String? asset;
final String? imageUrl;
@override
Widget build(BuildContext context) {
final resolvedImageUrl = imageUrl?.trim() ?? '';
final resolvedAsset = asset?.trim() ?? '';
if (resolvedImageUrl.isNotEmpty || resolvedAsset.isNotEmpty) {
return _RocketImage(
asset: resolvedAsset.isEmpty ? null : resolvedAsset,
imageUrl: resolvedImageUrl.isEmpty ? null : resolvedImageUrl,
fit: BoxFit.cover,
);
}
return const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F6B55), Color(0xFF031F19)],
),
),
child: Icon(Icons.person, color: Color(0xFF8CFFE3), size: 14),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.text, required this.scale});
final String text;
final double scale;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
_SlashGroup(scale: scale),
SizedBox(width: 4 * scale),
Flexible(
child: Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF75F3CB),
fontSize: 14 * scale,
height: 1,
decoration: TextDecoration.none,
),
),
),
SizedBox(width: 4 * scale),
Transform.rotate(angle: math.pi, child: _SlashGroup(scale: scale)),
],
);
}
}
class _SlashGroup extends StatelessWidget {
const _SlashGroup({required this.scale});
final double scale;
@override
Widget build(BuildContext context) {
return Text(
'///',
textScaler: TextScaler.noScaling,
style: TextStyle(
color: const Color(0xFF75F3CB),
fontSize: 10 * scale,
height: 1,
letterSpacing: 0,
decoration: TextDecoration.none,
),
);
}
}
class _RocketImage extends StatelessWidget {
const _RocketImage({this.asset, this.imageUrl, this.fit = BoxFit.cover});
final String? asset;
final String? imageUrl;
final BoxFit fit;
@override
Widget build(BuildContext context) {
if (imageUrl != null && imageUrl!.trim().isNotEmpty) {
return CustomCachedImage(imageUrl: imageUrl!, fit: fit);
}
if (asset != null && asset!.trim().isNotEmpty) {
return Image.asset(asset!, fit: fit, gaplessPlayback: true);
}
return const SizedBox.shrink();
}
}
class _GradientText extends StatelessWidget {
const _GradientText(
this.text, {
required this.fontSize,
this.fontWeight = FontWeight.w400,
this.textAlign = TextAlign.left,
});
final String text;
final double fontSize;
final FontWeight fontWeight;
final TextAlign textAlign;
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback:
(bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Colors.white, Color(0xFFA4FFE3)],
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Text(
text,
textScaler: TextScaler.noScaling,
maxLines: 1,
textAlign: textAlign,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1,
fontWeight: fontWeight,
decoration: TextDecoration.none,
),
),
);
}
}
class _NoGlowScrollBehavior extends ScrollBehavior {
const _NoGlowScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
}
class _RewardDetailCardPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final panelPath = _scaledPath(
const [
Offset(88.293, 0.5),
Offset(96.5, 8.70703),
Offset(96.5, 92.293),
Offset(88.293, 100.5),
Offset(8.70703, 100.5),
Offset(0.5, 92.293),
Offset(0.5, 8.70703),
Offset(8.70703, 0.5),
],
size,
const Size(97, 101),
);
canvas.drawPath(
panelPath,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF077C60), Color(0xFF08251E)],
).createShader(Offset.zero & size),
);
canvas.drawPath(
panelPath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width / 97
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFFB2FBCC),
const Color(0xFFB2FBCC).withValues(alpha: 0),
],
).createShader(Offset.zero & size),
);
final pillPath = _scaledPath(
const [
Offset(13, 74.8367),
Offset(18.6975, 69),
Offset(78.3025, 69),
Offset(84, 74.8367),
Offset(84, 85.1633),
Offset(78.3025, 91),
Offset(18.6975, 91),
Offset(13, 85.1633),
],
size,
const Size(97, 101),
);
canvas.drawPath(
pillPath,
Paint()
..shader = const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFF136648), Color(0xFF06412B), Color(0xFF136648)],
).createShader(Offset.zero & size),
);
canvas.drawPath(
pillPath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width / 97
..color = const Color(0xFF8BF9FE).withValues(alpha: 0.85),
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _RowDivider extends StatelessWidget {
const _RowDivider();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0),
Colors.white.withValues(alpha: 0.16),
Colors.white.withValues(alpha: 0),
],
),
),
);
}
}
Path _scaledPath(List<Offset> points, Size size, Size viewBox) {
final path = Path();
for (var index = 0; index < points.length; index++) {
final point = Offset(
points[index].dx / viewBox.width * size.width,
points[index].dy / viewBox.height * size.height,
);
if (index == 0) {
path.moveTo(point.dx, point.dy);
} else {
path.lineTo(point.dx, point.dy);
}
}
return path..close();
}
class _RewardDialogAssets {
const _RewardDialogAssets._();
static const String panelTop = 'sc_images/room/rocket/reward_dialog_top.png';
static const String panelMiddle =
'sc_images/room/rocket/reward_dialog_middle.png';
static const String panelBottom =
'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 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';
static const String rocketBody = 'sc_images/room/rocket/rocket_body.png';
}
const double _designWidth = 375;
const double _designHeight = 812;

View File

@ -13,6 +13,7 @@ import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart'; import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart';
import 'package:yumi/ui_kit/widgets/room/room_banner_view.dart'; import 'package:yumi/ui_kit/widgets/room/room_banner_view.dart';
import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart'; import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart';
class RoomPlayWidget extends StatefulWidget { class RoomPlayWidget extends StatefulWidget {
const RoomPlayWidget({super.key}); const RoomPlayWidget({super.key});
@ -34,6 +35,11 @@ class _RoomPlayWidgetState extends State<RoomPlayWidget> {
bottom: (150 - _rightActionDownShift).w, bottom: (150 - _rightActionDownShift).w,
child: const RoomMusicFloatingEntry(), child: const RoomMusicFloatingEntry(),
), ),
PositionedDirectional(
end: 65.w,
bottom: (150 - _rightActionDownShift).w,
child: const RoomRocketFloatingEntry(),
),
PositionedDirectional( PositionedDirectional(
end: 15.w, end: 15.w,
bottom: (200 - _rightActionDownShift).w, bottom: (200 - _rightActionDownShift).w,

View File

@ -166,10 +166,13 @@ flutter:
- sc_images/daily_sign_in/ - sc_images/daily_sign_in/
- sc_images/register_reward/ - sc_images/register_reward/
- sc_images/room/ - sc_images/room/
- sc_images/room/cp_progress/
- sc_images/room/cp_invite/
- sc_images/room/background_examples/ - sc_images/room/background_examples/
- sc_images/room/emoji/ - sc_images/room/emoji/
- sc_images/room/entrance/ - sc_images/room/entrance/
- sc_images/room/red_packet/ - sc_images/room/red_packet/
- sc_images/room/rocket/
- sc_images/room/anim/ - sc_images/room/anim/
- sc_images/room/anim/gift/ - sc_images/room/anim/gift/
- sc_images/room/anim/luck_gift/ - sc_images/room/anim/luck_gift/

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_screen.dart';
import 'package:yumi/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart';
void main() {
runApp(const RoomCpInvitePreviewApp());
}
class RoomCpInvitePreviewApp extends StatelessWidget {
const RoomCpInvitePreviewApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: Size(SCScreen.designWidth, SCScreen.designHeight),
splitScreenMode: false,
minTextAdapt: true,
builder: (context, child) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: RoomCpInvitePreviewPage(),
);
},
);
}
}
class RoomCpInvitePreviewPage extends StatelessWidget {
const RoomCpInvitePreviewPage({super.key});
static const _previewStyle = String.fromEnvironment(
'CP_PREVIEW_STYLE',
defaultValue: 'waiting',
);
static const _inviter = RoomCpInviteDialogUser(name: 'NameName');
static const _receiver = RoomCpInviteDialogUser(name: 'NameName');
@override
Widget build(BuildContext context) {
final style =
_previewStyle == 'incoming' ||
Uri.base.queryParameters['style'] == 'incoming'
? RoomCpInviteDialogStyle.incoming
: RoomCpInviteDialogStyle.waiting;
return Scaffold(
body: SizedBox.expand(
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2C1620), Color(0xFF171C31), Color(0xFF07070B)],
),
),
child: Stack(
children: [
RoomCpInviteDialog(
inviter: _inviter,
receiver: _receiver,
style: style,
countdownSeconds: 86399,
autoDismiss: false,
),
],
),
),
),
);
}
}

View File

@ -1,3 +1,88 @@
# CP 进度弹窗 Desktop MCP 复核进度
- [x] 2026-05-14 用户确认Figma 背景图、具体头像等仅用于截图校验,测试完成后不能留在包资源中
- [x] 2026-05-14 已删除校验专用资源:`sc_cp_progress_preview_bg.png``sc_cp_progress_sample_avatar.png`
- [x] 2026-05-14 已移除 `RoomCpProgressDialogUser.avatarAsset` 以及预览入口对本地示例头像/背景图的引用
- [x] 2026-05-14 已执行 `dart format`,格式化通过
- [x] 2026-05-14 已执行 `flutter analyze lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart lib/debug/room_cp_progress_dialog_preview.dart`结果No issues found
- [x] 2026-05-14 已检查残留引用:代码和资源目录无校验背景/示例头像引用,相关字符串仅保留在 `需求进度.md` 历史记录
- [x] 2026-05-14 已收尾检查 git 状态和 CP 进度资源目录;`cp_progress` 目录仅保留弹窗生产所需 6 个素材
---
# CP 进度弹窗 Desktop MCP 复核进度2026-05-13
- [x] 2026-05-13 用户已切换要求:本轮优先使用 Figma Desktop MCP`127.0.0.1:3845/mcp`),不再使用 Remote MCP
- [x] 2026-05-13 已重新读取 `figma-implement-design` 工作流,确认先 metadata、design context、screenshot再进入编码修正
- [x] 2026-05-13 已通过 Figma Desktop MCP 读取 `7:1088` metadata该节点本身为 375x812 framename: `关系邀请4`),内容匹配 `<语音房-cp进度弹窗>`
- [x] 2026-05-13 已通过 Figma Desktop MCP 读取 `7:1088` design context 与 screenshot关键尺寸弹窗背景 340x275、中心图标 114x87、头像框 59x69、按钮 115x36、进度条背景 260x26
- [x] 2026-05-13 已下载 Figma 头像框资产到 `sc_images/room/cp_progress/sc_cp_progress_avatar_frame.png`
- [x] 2026-05-13 已按 Figma Desktop design context 重构 `RoomCpProgressDialog` 布局:中心天数卡 114x87、头像框 59x69、进度条/按钮按 Figma 位置复原
- [x] 2026-05-13 已下载 Figma 预览背景 `sc_cp_progress_preview_bg.png` 与示例头像 `sc_cp_progress_sample_avatar.png`
- [x] 2026-05-13 已更新预览入口为 Figma 背景/遮罩/示例头像;组件新增 `avatarAsset` 兜底字段,接口头像 URL 仍优先
- [x] 2026-05-13 已执行 `dart format`,格式化通过
- [x] 2026-05-13 已执行 `flutter analyze lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart lib/debug/room_cp_progress_dialog_preview.dart`结果No issues found
- [x] 2026-05-13 已在 iPhone 17 Pro 模拟器运行 `lib/debug/room_cp_progress_dialog_preview.dart`
- [x] 2026-05-13 已截取 iPhone 17 Pro 运行图:`build/cp_progress_verification/ios17pro_cp_progress_desktop_mcp_v1.png`
- [!] 2026-05-13 与 Figma screenshot 对比:整体位置/背景/中心天数卡/进度条/按钮接近,示例头像裁切仍偏离 Figma
- [x] 2026-05-13 已按 Figma mask 偏移修正 `avatarAsset` 预览裁切
- [x] 2026-05-13 已重新截图:`build/cp_progress_verification/ios17pro_cp_progress_desktop_mcp_v2.png`;视觉复查整体位置、比例、头像裁切、层级、文字和按钮接近 Desktop MCP 截图
- [x] 2026-05-13 已执行最终 `flutter analyze lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart lib/debug/room_cp_progress_dialog_preview.dart`结果No issues found
- [x] 2026-05-13 已退出 iPhone 17 Pro 运行 session
- [x] 2026-05-13 已收尾检查 git 状态和本轮修改文件;当前仍存在既有未跟踪 CP 邀请相关文件,最终回复中单独说明
---
# CP 进度弹窗需求进度
- [x] 2026-05-13 已确认工作目录:`/Users/nigger/Documents/GitHub/chatapp3-flutter`
- [x] 2026-05-13 已定位 Figma 实现相关 skill 文件,准备读取 workflow 约束
- [x] 2026-05-13 已读取 `figma-implement-design` 工作流,确认需先获取结构化设计信息与截图
- [x] 2026-05-13 已尝试读取 Figma 节点结构化设计信息与截图fileKey: `L2A45c2lm8Gt0VHS3beS4o`, nodeId: `636:849`
- [!] 2026-05-13 Figma MCP 返回 Starter plan 调用次数上限,当前不是 Remote MCP 未加载,也不是 OAuth 失效;本次实现将继续以本地设计图为最终视觉基准
- [x] 2026-05-13 已初步检查项目结构Flutter 项目,入口在 `lib/main.dart`,资源通过 `pubspec.yaml` 声明 `sc_images/...`
- [x] 2026-05-13 已确认现有弹窗依赖:`flutter_smart_dialog`;房间相关 UI 位于 `lib/ui_kit/widgets/room/...`
- [x] 2026-05-13 已找到本地设计素材:`/Users/nigger/Desktop/app素材/cp/cp进度弹窗`5 个 PNG
- [x] 2026-05-13 已读取本地素材尺寸:弹窗背景 680x550、内图标 456x348、进度条背景 520x52、进度条 484x32、取消按钮 230x72
- [x] 2026-05-13 已查看本地设计素材视觉内容,确认按 2x 逻辑尺寸实现:弹窗 340x275、进度槽 260x26、进度条 242x16、按钮 115x36、内图标 228x174
- [x] 2026-05-13 已检查现有 CP 邀请弹窗:`RoomCpInviteDialog` 使用 `SmartDialog.show``ScreenUtil``SCDebounceWidget`
- [x] 2026-05-13 已检查头像/图片组件:复用 `head()` / `netImage()`,默认头像资源为 `sc_images/general/sc_icon_avar_defalt.png`
- [x] 2026-05-13 已补充检查 CP 邀请弹窗尾部实现:按钮图片叠文字、关闭按钮使用 `SCDebounceWidget`
- [x] 2026-05-13 已确认现有 CP 资产命名位于 `sc_images/room/cp_invite/`
- [x] 2026-05-13 已检查 git 状态:当前已有 `pubspec.yaml``需求进度.md` 修改,以及未跟踪的 CP 邀请弹窗/素材目录
- [x] 2026-05-13 已复制 CP 进度弹窗素材到 `sc_images/room/cp_progress/`
- [x] 2026-05-13 已新增独立组件:`lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart`
- [x] 2026-05-13 已新增浏览器验证入口:`lib/debug/room_cp_progress_dialog_preview.dart`
- [x] 2026-05-13 已格式化新增 Dart 文件
- [x] 2026-05-13 已执行 `flutter analyze lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart lib/debug/room_cp_progress_dialog_preview.dart`结果No issues found
- [x] 2026-05-13 已读取 browser-use 本地验证说明;当前将使用 Flutter web-server + 浏览器截图验证
- [!] 2026-05-13 已尝试启动 Flutter web-server 预览,但项目当前 web 编译被既有 `image_cropper_for_web` / `image_cropper_platform_interface` 依赖错误阻塞
- [x] 2026-05-13 已检查设备iPhone 17 Pro Max 模拟器已启动(`8D2D87EB-4769-43B7-8DC2-580D46A76B6C`
- [!] 2026-05-13 iOS 预览已启动,但新增 `sc_images/room/cp_progress/` 未被 asset bundle 收录,需要追加到 `pubspec.yaml`
- [x] 2026-05-13 已在 `pubspec.yaml` 追加 `sc_images/room/cp_progress/` 资源声明
- [x] 2026-05-13 已重新运行 iPhone 17 Pro Max 模拟器预览,资源加载无缺失异常
- [x] 2026-05-13 已截图 iPhone 17 Pro Max 预览:`build/cp_progress_verification/ios_cp_progress_preview.png`
- [!] 2026-05-13 视觉检查发现用户信息行与顶部心形标题条重叠,需要下移用户区并调整标题等级胶囊
- [x] 2026-05-13 已调整用户区、时间、进度条和按钮纵向位置
- [!] 2026-05-13 热重载时 Flutter session 断开,需要重新启动预览入口
- [x] 2026-05-13 已重新截图:`build/cp_progress_verification/ios_cp_progress_preview_v2.png`
- [!] 2026-05-13 复查发现 ID 字段空间偏窄,长 ID 过早截断,需要扩展用户信息位
- [x] 2026-05-13 已扩展用户信息位并缩小头像到 40.w
- [!] 2026-05-13 热重载再次断开,改用重新启动预览入口完成最终截图
- [x] 2026-05-13 已重新启动预览入口并截最终图:`build/cp_progress_verification/ios_cp_progress_preview_final.png`
- [x] 2026-05-13 最终视觉复查头像、ID、时间、等级、进度条、按钮无明显重叠长 ID 可完整展示
- [x] 2026-05-13 最终执行 `flutter analyze lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart lib/debug/room_cp_progress_dialog_preview.dart`结果No issues found
- [x] 2026-05-13 已退出 Flutter run session未留下后台命令占用
- [x] 2026-05-13 已检查最终变更范围并准备汇总
- [x] 2026-05-13 已实现语音房内 CP 进度弹窗独立组件
- [x] 2026-05-13 已接入头像、ID、时间、等级等接口数据预留位与占位数据
- [x] 2026-05-13 已启动 iPhone 17 Pro Max 模拟器并完成截图验证
- [x] 2026-05-13 已按本地设计素材视觉基准迭代修正布局
- [x] 2026-05-13 待最终回复汇总修改文件、运行方式和待确认差异
- [x] 2026-05-13 已复查最终 git 状态:`tool/room_cp_invite_preview.dart``room_cp_invite_dialog.dart``sc_images/room/cp_invite/` 为既有 CP 邀请相关未跟踪项,不计入本次 CP 进度弹窗修改
---
# 需求进度 # 需求进度
## 当前总目标 ## 当前总目标