🚀 UI
@ -720,5 +720,8 @@
|
||||
"enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.",
|
||||
"voiceConnectionFailedRetry": "فشل الاتصال الصوتي. يرجى المحاولة مرة أخرى.",
|
||||
"doYouWantToKeepTheDraft": "هل تريد الاحتفاظ بالمسودة؟",
|
||||
"duration2": "المدة:{1}"
|
||||
"duration2": "المدة:{1}",
|
||||
"roomRocketNoteTitle": "ملاحظة",
|
||||
"roomRocketNoteSectionTitle": "في كل مرة يتم فيها شراء مستوى VIP",
|
||||
"roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية."
|
||||
}
|
||||
|
||||
@ -720,5 +720,8 @@
|
||||
"roomMusicListMode": "ক্রম অনুসারে চালান",
|
||||
"roomMusicShuffleMode": "শাফল চালান",
|
||||
"roomMusicSingleMode": "একক গান পুনরাবৃত্তি",
|
||||
"duration2": "সময়:{1}"
|
||||
"duration2": "সময়:{1}",
|
||||
"roomRocketNoteTitle": "নোট",
|
||||
"roomRocketNoteSectionTitle": "প্রতিবার VIP লেভেল কেনার সময়",
|
||||
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।"
|
||||
}
|
||||
|
||||
@ -720,5 +720,8 @@
|
||||
"roomMusicListMode": "Play in order",
|
||||
"roomMusicShuffleMode": "Shuffle play",
|
||||
"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."
|
||||
}
|
||||
|
||||
@ -720,5 +720,8 @@
|
||||
"roomMusicListMode": "Sırayla çal",
|
||||
"roomMusicShuffleMode": "Karışık çal",
|
||||
"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."
|
||||
}
|
||||
|
||||
@ -79,6 +79,18 @@ class SCRoomMsgType {
|
||||
///用户火箭中奖
|
||||
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";
|
||||
|
||||
|
||||
72
lib/debug/room_cp_progress_dialog_preview.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
160
lib/debug/room_rocket_dialog_preview.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/debug/room_rocket_empty_dialog_preview.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/debug/room_rocket_note_dialog_preview.dart
Normal 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)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/debug/room_rocket_rank_preview.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/debug/room_rocket_record_dialog_preview.dart
Normal 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)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/debug/room_rocket_reward_dialog_preview.dart
Normal 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)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1097
lib/modules/room/rocket/room_rocket_rank_page.dart
Normal 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/edit/room_edit_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/voice_room_page.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 roomBackgroundUpload = '/room/background/upload';
|
||||
static String roomMusic = '/room/music';
|
||||
static String roomRocketRank = '/room/rocket/rank';
|
||||
static int _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
|
||||
void initRouter(FluroRouter router) {
|
||||
router.define(
|
||||
@ -265,6 +294,10 @@ class VoiceRoomRoute implements SCIRouterProvider {
|
||||
roomMusic,
|
||||
handler: Handler(handlerFunc: (_, params) => const RoomMusicPage()),
|
||||
);
|
||||
router.define(
|
||||
roomRocketRank,
|
||||
handler: Handler(handlerFunc: (_, params) => const RoomRocketRankPage()),
|
||||
);
|
||||
router.define(
|
||||
roomEdit,
|
||||
handler: Handler(
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:yumi/app_localizations.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_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/sources/local/floating_screen_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/tools/sc_room_profile_cache.dart';
|
||||
import '../../ui_kit/components/sc_float_ichart.dart';
|
||||
import '../../ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart';
|
||||
import '../../ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart';
|
||||
|
||||
typedef OnSoundVoiceChange = Function(num index, int volum);
|
||||
typedef RtcProvider = RealTimeCommunicationManager;
|
||||
@ -288,6 +291,8 @@ class _PendingRoomStartupSeatAction {
|
||||
}
|
||||
|
||||
class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog';
|
||||
|
||||
static const Duration _micListPollingInterval = Duration(seconds: 2);
|
||||
static const Duration _onlineUsersPollingInterval = Duration(seconds: 3);
|
||||
static const int _exitNetworkRetryLimit = 3;
|
||||
@ -330,6 +335,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
Timer? _joinAgoraTimeoutTimer;
|
||||
Timer? _agoraDisconnectedCleanupTimer;
|
||||
Timer? _roomEntryEffectTimer;
|
||||
Timer? _roomRocketLaunchAnimationTimer;
|
||||
Timer? _roomRedPacketPresenceTimer;
|
||||
Completer<void>? _joinAgoraCompleter;
|
||||
Future<RtcEngine>? _rtcEngineInitTask;
|
||||
@ -477,6 +483,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
if (!enabled) {
|
||||
_roomEntryEffectTimer?.cancel();
|
||||
_roomEntryEffectTimer = null;
|
||||
_roomRocketLaunchAnimationTimer?.cancel();
|
||||
_roomRocketLaunchAnimationTimer = null;
|
||||
}
|
||||
_roomVisualEffectsEnabled = enabled;
|
||||
notifyListeners();
|
||||
@ -2864,29 +2872,118 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _loadRoomSecondaryData() {
|
||||
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
|
||||
SCChatRoomRepository().rocketClaim(roomId).catchError((e) {
|
||||
return true; // 错误已处理
|
||||
});
|
||||
SCChatRoomRepository()
|
||||
.rocketClaim(currenRoom?.roomProfile?.roomProfile?.id ?? "")
|
||||
.catchError((e) {
|
||||
return true; // 错误已处理
|
||||
});
|
||||
SCChatRoomRepository()
|
||||
.rocketStatus(currenRoom?.roomProfile?.roomProfile?.id ?? "")
|
||||
.rocketStatus(roomId)
|
||||
.then((res) {
|
||||
roomRocketStatus = res;
|
||||
notifyListeners();
|
||||
})
|
||||
.catchError((e) {});
|
||||
_loadRoomRocketRewardPopups(roomId);
|
||||
_refreshRoomRedPacketListAfterEntry();
|
||||
|
||||
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) {
|
||||
roomRocketStatus = res;
|
||||
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 {
|
||||
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
|
||||
@ -3352,6 +3449,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
_stopVoiceRoomForegroundService();
|
||||
_roomEntryEffectTimer?.cancel();
|
||||
_roomEntryEffectTimer = null;
|
||||
_roomRocketLaunchAnimationTimer?.cancel();
|
||||
_roomRocketLaunchAnimationTimer = null;
|
||||
_stopRoomRedPacketPresenceHeartbeat();
|
||||
_previewRoomSeatCount = null;
|
||||
_finishRoomStartupSeatLoading(executePendingAction: false);
|
||||
|
||||
@ -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_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/room_rocket_launch_broadcast_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/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_open_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
|
||||
import 'package:yumi/services/gift/gift_system_manager.dart';
|
||||
@ -103,6 +106,8 @@ class _LuckGiftPushQueueEntry {
|
||||
}
|
||||
|
||||
class RealTimeMessagingManager extends ChangeNotifier {
|
||||
static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog';
|
||||
|
||||
static const int _giftComboMergeWindowMs = 3000;
|
||||
static const int _maxLuckGiftPushQueueLength = 12;
|
||||
static const int _luckyGiftFloatMinMultiple = 5;
|
||||
@ -1785,6 +1790,123 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
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) {
|
||||
if (value is Iterable) {
|
||||
return value
|
||||
@ -1962,6 +2084,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
priority: 1000,
|
||||
);
|
||||
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) {
|
||||
///红包触发飘屏
|
||||
var fData = data["data"];
|
||||
@ -2050,6 +2178,18 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
// 直接处理字符串格式的自定义数据
|
||||
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) {
|
||||
///房间红包
|
||||
var fData = data["data"];
|
||||
|
||||
403
lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart
Normal 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;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart';
|
||||
|
||||
/// roomId : 0
|
||||
/// date : ""
|
||||
/// level : 0
|
||||
@ -35,6 +37,15 @@ class SCRoomRocketStatusRes {
|
||||
bool? hasClaimed,
|
||||
num? myContribution,
|
||||
num? myContributionRate,
|
||||
int? roundNo,
|
||||
bool? displayRound,
|
||||
int? currentLevel,
|
||||
int? needEnergy,
|
||||
num? displayPercent,
|
||||
bool? shake,
|
||||
List<SCRoomRocketLevelRes>? levels,
|
||||
SCRoomRocketRewardPreviewRes? rewardPreview,
|
||||
List<SCRoomRocketKingUserRes>? rocketKings,
|
||||
}) {
|
||||
_roomId = roomId;
|
||||
_date = date;
|
||||
@ -52,25 +63,47 @@ class SCRoomRocketStatusRes {
|
||||
_hasClaimed = hasClaimed;
|
||||
_myContribution = myContribution;
|
||||
_myContributionRate = myContributionRate;
|
||||
_roundNo = roundNo;
|
||||
_displayRound = displayRound;
|
||||
_currentLevel = currentLevel;
|
||||
_needEnergy = needEnergy;
|
||||
_displayPercent = displayPercent;
|
||||
_shake = shake;
|
||||
_levels = levels;
|
||||
_rewardPreview = rewardPreview;
|
||||
_rocketKings = rocketKings;
|
||||
}
|
||||
|
||||
SCRoomRocketStatusRes.fromJson(dynamic json) {
|
||||
_roomId = json['roomId'];
|
||||
_date = json['date'];
|
||||
_level = json['level'];
|
||||
_date = json['date'] ?? json['dayKey'];
|
||||
_roundNo = _intValue(json['roundNo']);
|
||||
_displayRound = _boolValue(json['displayRound']);
|
||||
_currentLevel = _intValue(json['currentLevel']);
|
||||
_needEnergy = _intValue(json['needEnergy']);
|
||||
_displayPercent = _numValue(json['displayPercent']);
|
||||
_shake = _boolValue(json['shake']);
|
||||
_level = json['level'] ?? _currentLevel;
|
||||
_levelName = json['levelName'];
|
||||
_currentEnergy = json['currentEnergy'];
|
||||
_maxEnergy = json['maxEnergy'];
|
||||
_energyPercent = json['energyPercent'];
|
||||
_currentEnergy = _intValue(json['currentEnergy']);
|
||||
_maxEnergy = _intValue(json['maxEnergy']) ?? _needEnergy;
|
||||
_energyPercent = _numValue(json['energyPercent']) ?? _displayPercent;
|
||||
_status = json['status'];
|
||||
_statusDesc = json['statusDesc'];
|
||||
_totalContributors = json['totalContributors'];
|
||||
_rocketKings = _kingList(json['rocketKings']);
|
||||
_totalContributors =
|
||||
json['totalContributors'] ?? (_rocketKings?.length ?? 0);
|
||||
_claimExpireTime = json['claimExpireTime'];
|
||||
_claimCount = json['claimCount'];
|
||||
_canClaim = json['canClaim'];
|
||||
_hasClaimed = json['hasClaimed'];
|
||||
_myContribution = json['myContribution'];
|
||||
_myContributionRate = json['myContributionRate'];
|
||||
_levels = _levelList(json['levels']);
|
||||
_rewardPreview =
|
||||
json['rewardPreview'] == null
|
||||
? null
|
||||
: SCRoomRocketRewardPreviewRes.fromJson(json['rewardPreview']);
|
||||
}
|
||||
|
||||
String? _roomId;
|
||||
@ -89,6 +122,15 @@ class SCRoomRocketStatusRes {
|
||||
bool? _hasClaimed;
|
||||
num? _myContribution;
|
||||
num? _myContributionRate;
|
||||
int? _roundNo;
|
||||
bool? _displayRound;
|
||||
int? _currentLevel;
|
||||
int? _needEnergy;
|
||||
num? _displayPercent;
|
||||
bool? _shake;
|
||||
List<SCRoomRocketLevelRes>? _levels;
|
||||
SCRoomRocketRewardPreviewRes? _rewardPreview;
|
||||
List<SCRoomRocketKingUserRes>? _rocketKings;
|
||||
|
||||
String? get roomId => _roomId;
|
||||
|
||||
@ -122,6 +164,24 @@ class SCRoomRocketStatusRes {
|
||||
|
||||
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;
|
||||
@ -140,6 +200,57 @@ class SCRoomRocketStatusRes {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/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_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_task_claimable_count_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<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();
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -16,6 +16,7 @@ class SCFloatingMessage {
|
||||
num? coins;
|
||||
num? number;
|
||||
num? multiple;
|
||||
num? durationSeconds;
|
||||
String? broadcastScope; // region:来自语区 IM 群,只能按区域飘屏规则展示
|
||||
int priority = 10; //排序权重
|
||||
|
||||
@ -34,6 +35,7 @@ class SCFloatingMessage {
|
||||
this.giftId = '',
|
||||
this.number = 0,
|
||||
this.coins = 0,
|
||||
this.durationSeconds,
|
||||
this.priority = 10,
|
||||
this.multiple = 10,
|
||||
this.broadcastScope = '',
|
||||
@ -54,6 +56,7 @@ class SCFloatingMessage {
|
||||
giftId = json['giftId'];
|
||||
coins = json['coins'];
|
||||
number = json['number'];
|
||||
durationSeconds = json['durationSeconds'];
|
||||
priority = json['priority'];
|
||||
multiple = json['multiple'];
|
||||
broadcastScope = json['broadcastScope'];
|
||||
@ -77,6 +80,7 @@ class SCFloatingMessage {
|
||||
map['giftId'] = giftId;
|
||||
map['coins'] = coins;
|
||||
map['number'] = number;
|
||||
map['durationSeconds'] = durationSeconds;
|
||||
map['priority'] = priority;
|
||||
map['multiple'] = multiple;
|
||||
map['broadcastScope'] = broadcastScope;
|
||||
|
||||
@ -162,14 +162,21 @@ class OverlayManager {
|
||||
}
|
||||
|
||||
String? _floatingDedupKeyFor(SCFloatingMessage message) {
|
||||
if (message.type != 4) {
|
||||
return null;
|
||||
if (message.type == 3) {
|
||||
final launchNo = message.giftId?.trim() ?? '';
|
||||
if (launchNo.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return 'room_rocket_launch|$launchNo';
|
||||
}
|
||||
final packetId = message.toUserId?.trim() ?? '';
|
||||
if (packetId.isEmpty) {
|
||||
return null;
|
||||
if (message.type == 4) {
|
||||
final packetId = message.toUserId?.trim() ?? '';
|
||||
if (packetId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return 'room_red_packet|$packetId';
|
||||
}
|
||||
return 'room_red_packet|$packetId';
|
||||
return null;
|
||||
}
|
||||
|
||||
String _compensationKeyFor(SCFloatingMessage message) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:yumi/app/constants/sc_room_msg_type.dart';
|
||||
import 'package:yumi/shared/business_logic/models/req/sc_give_away_gift_room_acceptscmd.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/gift_res.dart';
|
||||
@ -13,6 +14,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_detail_
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_grab_res.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_list_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_api_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_status_res.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/sc_room_task_list_res.dart';
|
||||
@ -58,7 +60,6 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
dynamic data,
|
||||
dynamic query,
|
||||
}) async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final result = await request();
|
||||
return result;
|
||||
@ -73,11 +74,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
String path,
|
||||
Object error,
|
||||
StackTrace stackTrace,
|
||||
) {
|
||||
if (error is DioException) {
|
||||
final request = error.requestOptions;
|
||||
} else {}
|
||||
}
|
||||
) {}
|
||||
|
||||
String _debugJson(dynamic value) {
|
||||
if (value == null) {
|
||||
@ -105,6 +102,26 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
}
|
||||
}
|
||||
|
||||
void _rocketApiDebug(
|
||||
String label, {
|
||||
dynamic query,
|
||||
dynamic data,
|
||||
dynamic raw,
|
||||
dynamic result,
|
||||
}) {
|
||||
if (!kDebugMode) {
|
||||
return;
|
||||
}
|
||||
final parts = <String>[
|
||||
'[RoomRocketApi] $label',
|
||||
if (query != null) 'query=${_debugJson(query)}',
|
||||
if (data != null) 'data=${_debugJson(data)}',
|
||||
if (raw != null) 'raw=${_debugJson(raw)}',
|
||||
if (result != null) 'result=${_debugResult(result)}',
|
||||
];
|
||||
debugPrint(parts.join(' '));
|
||||
}
|
||||
|
||||
dynamic _jsonInt64(String value) {
|
||||
final normalized = value.trim();
|
||||
return int.tryParse(normalized) ?? normalized;
|
||||
@ -756,16 +773,144 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
///api/rocket/status
|
||||
///go/app/voice-room/rocket/status
|
||||
@override
|
||||
Future<SCRoomRocketStatusRes> rocketStatus(String roomId) async {
|
||||
Map<String, dynamic> params = {};
|
||||
params["roomId"] = roomId;
|
||||
const path = "/go/app/voice-room/rocket/status";
|
||||
_rocketApiDebug('request GET $path', query: params);
|
||||
final result = await http.get<SCRoomRocketStatusRes>(
|
||||
"1b650f6fc863c8c86ec8d921340a65ccba2fafbaede21817c8d7e89774331852",
|
||||
path,
|
||||
queryParams: params,
|
||||
fromJson: (json) => SCRoomRocketStatusRes.fromJson(json),
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) {
|
||||
_rocketApiDebug('GET $path', query: params, raw: json);
|
||||
return SCRoomRocketStatusRes.fromJson(json);
|
||||
},
|
||||
);
|
||||
_rocketApiDebug('parsed $path', query: params, result: result);
|
||||
return result;
|
||||
}
|
||||
|
||||
///go/app/voice-room/rocket/king
|
||||
@override
|
||||
Future<SCRoomRocketKingRes> roomRocketKing(
|
||||
String roomId, {
|
||||
int level = 1,
|
||||
int roundNo = 0,
|
||||
int cursor = 1,
|
||||
int limit = 100,
|
||||
}) async {
|
||||
final queryParams = {
|
||||
"roomId": roomId,
|
||||
"level": level,
|
||||
"roundNo": roundNo,
|
||||
"cursor": cursor,
|
||||
"limit": limit,
|
||||
};
|
||||
const path = "/go/app/voice-room/rocket/king";
|
||||
_rocketApiDebug('request GET $path', query: queryParams);
|
||||
final result = await http.get<SCRoomRocketKingRes>(
|
||||
path,
|
||||
queryParams: queryParams,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) {
|
||||
_rocketApiDebug('GET $path', query: queryParams, raw: json);
|
||||
return SCRoomRocketKingRes.fromJson(json);
|
||||
},
|
||||
);
|
||||
_rocketApiDebug('parsed $path', query: queryParams, result: result);
|
||||
return result;
|
||||
}
|
||||
|
||||
///go/app/voice-room/rocket/reward-popups
|
||||
@override
|
||||
Future<SCRoomRocketRewardPopupRes> roomRocketRewardPopups(
|
||||
String roomId,
|
||||
) async {
|
||||
final queryParams = {if (roomId.trim().isNotEmpty) "roomId": roomId};
|
||||
const path = "/go/app/voice-room/rocket/reward-popups";
|
||||
_rocketApiDebug('request GET $path', query: queryParams);
|
||||
final result = await http.get<SCRoomRocketRewardPopupRes>(
|
||||
path,
|
||||
queryParams: queryParams,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) {
|
||||
_rocketApiDebug('GET $path', query: queryParams, raw: json);
|
||||
return SCRoomRocketRewardPopupRes.fromJson(json);
|
||||
},
|
||||
);
|
||||
_rocketApiDebug('parsed $path', query: queryParams, result: result);
|
||||
return result;
|
||||
}
|
||||
|
||||
///go/app/voice-room/rocket/reward-popups/ack
|
||||
@override
|
||||
Future<bool> ackRoomRocketRewardPopups(List<String> rewardRecordIds) async {
|
||||
final requestData = {"rewardRecordIds": rewardRecordIds};
|
||||
const path = "/go/app/voice-room/rocket/reward-popups/ack";
|
||||
_rocketApiDebug('request POST $path', data: requestData);
|
||||
final result = await http.post<bool>(
|
||||
path,
|
||||
data: requestData,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) {
|
||||
_rocketApiDebug('POST $path', data: requestData, raw: json);
|
||||
if (json is bool) {
|
||||
return json;
|
||||
}
|
||||
if (json is Map) {
|
||||
return json["success"] == true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
_rocketApiDebug('parsed $path', data: requestData, result: result);
|
||||
return result;
|
||||
}
|
||||
|
||||
///go/app/voice-room/rocket/reward-records
|
||||
@override
|
||||
Future<SCRoomRocketRewardRecordsRes> roomRocketRewardRecords(
|
||||
String roomId, {
|
||||
int cursor = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final queryParams = {
|
||||
if (roomId.trim().isNotEmpty) "roomId": roomId,
|
||||
"cursor": cursor,
|
||||
"limit": limit,
|
||||
};
|
||||
const path = "/go/app/voice-room/rocket/reward-records";
|
||||
_rocketApiDebug('request GET $path', query: queryParams);
|
||||
final result = await http.get<SCRoomRocketRewardRecordsRes>(
|
||||
path,
|
||||
queryParams: queryParams,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) {
|
||||
_rocketApiDebug('GET $path', query: queryParams, raw: json);
|
||||
return SCRoomRocketRewardRecordsRes.fromJson(json);
|
||||
},
|
||||
);
|
||||
_rocketApiDebug('parsed $path', query: queryParams, result: result);
|
||||
return result;
|
||||
}
|
||||
|
||||
///go/app/voice-room/rocket/rule
|
||||
@override
|
||||
Future<dynamic> roomRocketRule() async {
|
||||
const path = "/go/app/voice-room/rocket/rule";
|
||||
_rocketApiDebug('request GET $path');
|
||||
final result = await http.get<dynamic>(
|
||||
path,
|
||||
extra: {BaseNetworkClient.silentErrorToastKey: true},
|
||||
fromJson: (json) {
|
||||
_rocketApiDebug('GET $path', raw: json);
|
||||
return json;
|
||||
},
|
||||
);
|
||||
_rocketApiDebug('parsed $path', result: result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
728
lib/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
488
lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -76,10 +76,7 @@ class _FloatingRoomRocketScreenWidgetState
|
||||
_swipeAnimation = Tween<Offset>(
|
||||
begin: Offset.zero, // 从当前位置开始
|
||||
end: const Offset(-1.5, 0.0), // 到屏幕左侧外
|
||||
).animate(CurvedAnimation(
|
||||
parent: _swipeController,
|
||||
curve: Curves.easeIn,
|
||||
));
|
||||
).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn));
|
||||
|
||||
// 延迟启动动画
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@ -88,13 +85,13 @@ class _FloatingRoomRocketScreenWidgetState
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startAnimationSequence() async {
|
||||
// 第一阶段:从右到中
|
||||
_controller.reset();
|
||||
await _controller.forward();
|
||||
|
||||
// 停顿2秒(你代码中是3秒,这里保持原样)
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
await Future.delayed(_holdDuration);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@ -110,6 +107,12 @@ class _FloatingRoomRocketScreenWidgetState
|
||||
}
|
||||
}
|
||||
|
||||
Duration get _holdDuration {
|
||||
final totalSeconds = widget.message.durationSeconds;
|
||||
final totalMs = totalSeconds == null ? 6000 : (totalSeconds * 1000).round();
|
||||
final holdMs = totalMs - 3000;
|
||||
return Duration(milliseconds: holdMs.clamp(0, 10000).toInt());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -147,7 +150,7 @@ class _FloatingRoomRocketScreenWidgetState
|
||||
SCRoomUtils.goRoom(
|
||||
widget.message.roomId!,
|
||||
navigatorKey.currentState!.context,
|
||||
fromFloting: true
|
||||
fromFloting: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
@ -160,9 +163,7 @@ class _FloatingRoomRocketScreenWidgetState
|
||||
// 定义一个根据方向转换速度符号的辅助函数
|
||||
double effectiveVelocity(double velocity) {
|
||||
// 在RTL模式下,反转速度的正负号
|
||||
return textDirection == TextDirection.rtl
|
||||
? -velocity
|
||||
: velocity;
|
||||
return textDirection == TextDirection.rtl ? -velocity : velocity;
|
||||
}
|
||||
|
||||
double velocity = effectiveVelocity(details.primaryVelocity ?? 0);
|
||||
@ -225,8 +226,7 @@ class _FloatingRoomRocketScreenWidgetState
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
scrollAxis: Axis.horizontal,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
blankSpace: 20.0,
|
||||
velocity: 40.0,
|
||||
pauseAfterRound: Duration(seconds: 1),
|
||||
|
||||
192
lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart
Normal 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 '';
|
||||
}
|
||||
}
|
||||
1203
lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart
Normal file
450
lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
389
lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart
Normal 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;
|
||||
687
lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart
Normal 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;
|
||||
927
lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart
Normal 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;
|
||||
@ -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/room_banner_view.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 {
|
||||
const RoomPlayWidget({super.key});
|
||||
@ -34,6 +35,11 @@ class _RoomPlayWidgetState extends State<RoomPlayWidget> {
|
||||
bottom: (150 - _rightActionDownShift).w,
|
||||
child: const RoomMusicFloatingEntry(),
|
||||
),
|
||||
PositionedDirectional(
|
||||
end: 65.w,
|
||||
bottom: (150 - _rightActionDownShift).w,
|
||||
child: const RoomRocketFloatingEntry(),
|
||||
),
|
||||
PositionedDirectional(
|
||||
end: 15.w,
|
||||
bottom: (200 - _rightActionDownShift).w,
|
||||
|
||||
@ -166,10 +166,13 @@ flutter:
|
||||
- sc_images/daily_sign_in/
|
||||
- sc_images/register_reward/
|
||||
- sc_images/room/
|
||||
- sc_images/room/cp_progress/
|
||||
- sc_images/room/cp_invite/
|
||||
- sc_images/room/background_examples/
|
||||
- sc_images/room/emoji/
|
||||
- sc_images/room/entrance/
|
||||
- sc_images/room/red_packet/
|
||||
- sc_images/room/rocket/
|
||||
- sc_images/room/anim/
|
||||
- sc_images/room/anim/gift/
|
||||
- sc_images/room/anim/luck_gift/
|
||||
|
||||
BIN
sc_images/room/cp_invite/sc_cp_invite_accept_btn.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_avatar_frame.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_background.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_close.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_countdown.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_divider.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_gift.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_gift_burst.png
Normal file
|
After Width: | Height: | Size: 970 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_gift_floor.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_panel.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_reject_btn.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
sc_images/room/cp_invite/sc_cp_invite_title.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
sc_images/room/cp_progress/sc_cp_progress_avatar_frame.png
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
sc_images/room/cp_progress/sc_cp_progress_bar_bg.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
sc_images/room/cp_progress/sc_cp_progress_bar_fill.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
sc_images/room/cp_progress/sc_cp_progress_cancel_btn.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
sc_images/room/cp_progress/sc_cp_progress_dialog_bg.png
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
sc_images/room/cp_progress/sc_cp_progress_header.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
sc_images/room/rocket/back_arrow.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
sc_images/room/rocket/bottom_avatar_ring.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
sc_images/room/rocket/bottom_strip_active.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
sc_images/room/rocket/bottom_trophy.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
sc_images/room/rocket/coin.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
sc_images/room/rocket/pill_bg.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
sc_images/room/rocket/progress_empty.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
sc_images/room/rocket/progress_full.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
sc_images/room/rocket/progress_partial.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
sc_images/room/rocket/reward_dialog_bottom.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
sc_images/room/rocket/reward_dialog_middle.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
sc_images/room/rocket/reward_dialog_top.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
sc_images/room/rocket/reward_thumb.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
sc_images/room/rocket/rocket_body.png
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
sc_images/room/rocket/rocket_rank_bg.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
sc_images/room/rocket/rocket_rank_frame_1.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
sc_images/room/rocket/rocket_rank_frame_2.png
Normal file
|
After Width: | Height: | Size: 903 KiB |
BIN
sc_images/room/rocket/rocket_rank_frame_3.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
sc_images/room/rocket/rocket_stage.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
sc_images/room/rocket/side_panel.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
72
tool/room_cp_invite_preview.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
需求进度.md
@ -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 frame(name: `关系邀请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 进度弹窗修改
|
||||
|
||||
---
|
||||
|
||||
# 需求进度
|
||||
|
||||
## 当前总目标
|
||||
|
||||