diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index dbe1c4e..bf375c9 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -720,5 +720,8 @@ "enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.", "voiceConnectionFailedRetry": "فشل الاتصال الصوتي. يرجى المحاولة مرة أخرى.", "doYouWantToKeepTheDraft": "هل تريد الاحتفاظ بالمسودة؟", - "duration2": "المدة:{1}" -} + "duration2": "المدة:{1}", + "roomRocketNoteTitle": "ملاحظة", + "roomRocketNoteSectionTitle": "في كل مرة يتم فيها شراء مستوى VIP", + "roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية." +} diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index 5d7f2df..cfb2ca5 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -720,5 +720,8 @@ "roomMusicListMode": "ক্রম অনুসারে চালান", "roomMusicShuffleMode": "শাফল চালান", "roomMusicSingleMode": "একক গান পুনরাবৃত্তি", - "duration2": "সময়:{1}" + "duration2": "সময়:{1}", + "roomRocketNoteTitle": "নোট", + "roomRocketNoteSectionTitle": "প্রতিবার VIP লেভেল কেনার সময়", + "roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।" } diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index b5bdbd4..c0bdebc 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -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." } diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index 7f4c89d..5d5cd39 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -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." } diff --git a/lib/app/constants/sc_room_msg_type.dart b/lib/app/constants/sc_room_msg_type.dart index eacc5e3..4e3228b 100644 --- a/lib/app/constants/sc_room_msg_type.dart +++ b/lib/app/constants/sc_room_msg_type.dart @@ -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"; diff --git a/lib/debug/room_cp_progress_dialog_preview.dart b/lib/debug/room_cp_progress_dialog_preview.dart new file mode 100644 index 0000000..498ac4b --- /dev/null +++ b/lib/debug/room_cp_progress_dialog_preview.dart @@ -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', + ), + ), + ], + ), + ); + } +} diff --git a/lib/debug/room_rocket_dialog_preview.dart b/lib/debug/room_rocket_dialog_preview.dart new file mode 100644 index 0000000..8bc1c13 --- /dev/null +++ b/lib/debug/room_rocket_dialog_preview.dart @@ -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), + ), + ); + } +} diff --git a/lib/debug/room_rocket_empty_dialog_preview.dart b/lib/debug/room_rocket_empty_dialog_preview.dart new file mode 100644 index 0000000..5236362 --- /dev/null +++ b/lib/debug/room_rocket_empty_dialog_preview.dart @@ -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)), + ), + ); + } +} diff --git a/lib/debug/room_rocket_note_dialog_preview.dart b/lib/debug/room_rocket_note_dialog_preview.dart new file mode 100644 index 0000000..bb1b70b --- /dev/null +++ b/lib/debug/room_rocket_note_dialog_preview.dart @@ -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)], + ), + ), + ); + } +} diff --git a/lib/debug/room_rocket_rank_preview.dart b/lib/debug/room_rocket_rank_preview.dart new file mode 100644 index 0000000..a0e1bcb --- /dev/null +++ b/lib/debug/room_rocket_rank_preview.dart @@ -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(), + ); + } +} diff --git a/lib/debug/room_rocket_record_dialog_preview.dart b/lib/debug/room_rocket_record_dialog_preview.dart new file mode 100644 index 0000000..fc79c75 --- /dev/null +++ b/lib/debug/room_rocket_record_dialog_preview.dart @@ -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)], + ), + ), + ); + } +} diff --git a/lib/debug/room_rocket_reward_dialog_preview.dart b/lib/debug/room_rocket_reward_dialog_preview.dart new file mode 100644 index 0000000..c6dec74 --- /dev/null +++ b/lib/debug/room_rocket_reward_dialog_preview.dart @@ -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)], + ), + ), + ); + } +} diff --git a/lib/modules/room/rocket/room_rocket_rank_page.dart b/lib/modules/room/rocket/room_rocket_rank_page.dart new file mode 100644 index 0000000..0ffc19f --- /dev/null +++ b/lib/modules/room/rocket/room_rocket_rank_page.dart @@ -0,0 +1,1097 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; + +class RoomRocketRankPage extends StatefulWidget { + const RoomRocketRankPage({ + super.key, + this.roomId, + this.showRound2Today = true, + this.levelRanks, + this.currentUser, + this.initialLevel = 0, + }); + + final String? roomId; + final bool showRound2Today; + final List>? levelRanks; + final RoomRocketRankEntry? currentUser; + final int initialLevel; + + @override + State createState() => _RoomRocketRankPageState(); +} + +class RoomRocketRankEntry { + const RoomRocketRankEntry({ + required this.rank, + required this.nickname, + this.userId, + this.account, + this.avatarUrl, + this.avatarAsset, + }); + + final int rank; + final String nickname; + final String? userId; + final String? account; + final String? avatarUrl; + final String? avatarAsset; + + RoomRocketRankEntry copyWith({ + int? rank, + String? nickname, + String? userId, + String? account, + String? avatarUrl, + String? avatarAsset, + }) { + return RoomRocketRankEntry( + rank: rank ?? this.rank, + nickname: nickname ?? this.nickname, + userId: userId ?? this.userId, + account: account ?? this.account, + avatarUrl: avatarUrl ?? this.avatarUrl, + avatarAsset: avatarAsset ?? this.avatarAsset, + ); + } +} + +class _RoomRocketRankPageState extends State { + late int _selectedLevel; + final Map> _loadedRanks = {}; + final Map _loadedRoundNos = {}; + final Map _loadedCurrentUsers = {}; + final Set _loadingLevels = {}; + + static const int _levelCount = 6; + + @override + void initState() { + super.initState(); + _selectedLevel = widget.initialLevel.clamp(0, _levelCount - 1); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _loadLevel(_selectedLevel); + } + }); + } + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Color(0xFF010200), + systemNavigationBarIconBrightness: Brightness.light, + ), + child: Scaffold( + backgroundColor: const Color(0xFF010200), + extendBodyBehindAppBar: true, + body: LayoutBuilder( + builder: (context, constraints) { + return SizedBox.expand( + child: FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: _RocketRankLayout.designWidth, + height: _RocketRankLayout.designHeight, + child: _RocketRankCanvas( + selectedLevel: _selectedLevel, + showRound2Today: widget.showRound2Today, + roundNo: _loadedRoundNos[_selectedLevel] ?? 0, + ranksByLevel: _normalizedRanks, + currentUser: + widget.currentUser ?? + _currentUserEntryForLevel(_selectedLevel), + onLevelChanged: (index) { + setState(() => _selectedLevel = index); + _loadLevel(index); + }, + ), + ), + ), + ); + }, + ), + ), + ); + } + + List> get _normalizedRanks { + final providedRanks = widget.levelRanks; + if (providedRanks == null) { + final roomId = _currentRoomId(); + if (roomId.isEmpty) { + return List>.generate( + _levelCount, + (_) => const [], + ); + } + return List>.generate(_levelCount, (index) { + return _loadedRanks[index] ?? const []; + }); + } + return List>.generate(_levelCount, (index) { + if (index >= providedRanks.length) { + return const []; + } + return providedRanks[index]; + }); + } + + String _currentRoomId() { + final propRoomId = widget.roomId?.trim() ?? ''; + if (propRoomId.isNotEmpty) { + return propRoomId; + } + try { + return Provider.of( + context, + listen: false, + ).currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? + ''; + } catch (_) { + return ''; + } + } + + void _loadLevel(int index) { + if (widget.levelRanks != null || _loadingLevels.contains(index)) { + _rocketRankDebug( + 'skip king request level=${index + 1} provided=${widget.levelRanks != null} loading=${_loadingLevels.contains(index)}', + ); + return; + } + final roomId = _currentRoomId(); + if (roomId.isEmpty) { + _rocketRankDebug('skip king request because roomId is empty'); + return; + } + _loadingLevels.add(index); + _rocketRankDebug('request king roomId=$roomId level=${index + 1}'); + SCChatRoomRepository() + .roomRocketKing(roomId, level: index + 1) + .then((res) { + if (!mounted || roomId != _currentRoomId()) { + return; + } + final entries = _rankEntriesFromKing(res); + final currentUser = _currentUserEntryFromKing(res, entries); + setState(() { + _loadedRanks[index] = entries; + _loadedCurrentUsers[index] = currentUser; + _loadedRoundNos[index] = + res.sourceRoundNo > 0 ? res.sourceRoundNo : res.roundNo; + }); + }) + .catchError((_) { + _rocketRankDebug('king request failed level=${index + 1}'); + if (!mounted) { + return; + } + setState(() { + _loadedRanks[index] = const []; + }); + }) + .whenComplete(() { + _loadingLevels.remove(index); + }); + } + + List _rankEntriesFromKing(SCRoomRocketKingRes king) { + return king.displayRecords + .map( + (item) => RoomRocketRankEntry( + rank: item.rank, + nickname: item.displayName, + userId: item.userId, + account: item.account, + avatarUrl: item.userAvatar.isEmpty ? null : item.userAvatar, + ), + ) + .toList(growable: false); + } + + RoomRocketRankEntry _currentUserEntryFromKing( + SCRoomRocketKingRes king, + List entries, + ) { + final apiCurrentUser = king.currentUser; + if (apiCurrentUser != null) { + return _rankEntryFromKingUser(apiCurrentUser); + } + final matched = _findCurrentUserInEntries(entries); + if (matched != null) { + return matched; + } + return _currentAccountEntry(rank: 0); + } + + RoomRocketRankEntry _currentUserEntryForLevel(int levelIndex) { + final loadedCurrentUser = _loadedCurrentUsers[levelIndex]; + if (loadedCurrentUser != null) { + return loadedCurrentUser; + } + final ranks = + levelIndex < _normalizedRanks.length + ? _normalizedRanks[levelIndex] + : const []; + final matched = _findCurrentUserInEntries(ranks); + if (matched != null) { + return matched; + } + return _currentAccountEntry(rank: 0); + } + + RoomRocketRankEntry? _findCurrentUserInEntries( + List entries, + ) { + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + final currentUserId = (currentProfile?.id ?? '').trim(); + final currentAccount = (currentProfile?.account ?? '').trim(); + for (final entry in entries) { + final entryUserId = (entry.userId ?? '').trim(); + final entryAccount = (entry.account ?? '').trim(); + if (currentUserId.isNotEmpty && entryUserId == currentUserId) { + return entry; + } + if (currentAccount.isNotEmpty && entryAccount == currentAccount) { + return entry; + } + } + return null; + } + + RoomRocketRankEntry _rankEntryFromKingUser(SCRoomRocketKingUserRes user) { + return RoomRocketRankEntry( + rank: user.rank, + nickname: user.displayName.isNotEmpty ? user.displayName : 'You', + userId: user.userId, + account: user.account, + avatarUrl: user.userAvatar.isEmpty ? null : user.userAvatar, + ); + } + + RoomRocketRankEntry _currentAccountEntry({required int rank}) { + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + final nicknameCandidates = [ + currentProfile?.userNickname, + currentProfile?.account, + currentProfile?.id, + ]; + final nickname = nicknameCandidates + .map((item) => item?.trim() ?? '') + .firstWhere((item) => item.isNotEmpty, orElse: () => 'You'); + final avatarUrl = currentProfile?.userAvatar?.trim() ?? ''; + return RoomRocketRankEntry( + rank: rank, + nickname: nickname, + userId: currentProfile?.id, + account: currentProfile?.account, + avatarUrl: avatarUrl.isEmpty ? null : avatarUrl, + ); + } +} + +void _rocketRankDebug(String message) { + if (kDebugMode) { + debugPrint('[RoomRocketRank] $message'); + } +} + +class _RocketRankCanvas extends StatelessWidget { + const _RocketRankCanvas({ + required this.selectedLevel, + required this.showRound2Today, + required this.roundNo, + required this.ranksByLevel, + required this.currentUser, + required this.onLevelChanged, + }); + + final int selectedLevel; + final bool showRound2Today; + final int roundNo; + final List> ranksByLevel; + final RoomRocketRankEntry currentUser; + final ValueChanged onLevelChanged; + + @override + Widget build(BuildContext context) { + final entries = + selectedLevel < ranksByLevel.length + ? ranksByLevel[selectedLevel] + : const []; + + return DecoratedBox( + decoration: const BoxDecoration(color: Color(0xFF010200)), + child: Stack( + clipBehavior: Clip.none, + children: [ + const _RocketRankBackground(), + const _RocketRankNavigation(), + Positioned( + left: 0, + top: 100, + width: _RocketRankLayout.designWidth, + child: Visibility( + visible: showRound2Today && roundNo > 1, + child: _GradientText('Round ${roundNo > 0 ? roundNo : 1} Today'), + ), + ), + Positioned( + left: 0, + top: 124, + width: _RocketRankLayout.designWidth, + height: 22, + child: _RocketLevelTabs( + selectedIndex: selectedLevel, + onChanged: onLevelChanged, + ), + ), + if (entries.isEmpty) + const _RocketRankEmptyState() + else + _RocketRankListContent(entries: entries), + _RocketRankBottomBar(entry: currentUser), + const _RocketRankBackButton(), + ], + ), + ); + } +} + +class _RocketRankBackground extends StatelessWidget { + const _RocketRankBackground(); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + left: 0, + top: -1, + width: _RocketRankLayout.designWidth, + height: 665, + child: Opacity( + opacity: 0.24, + child: Transform.rotate( + angle: math.pi, + child: Image.asset( + _RocketRankAssets.background, + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + ), + ), + ), + ), + ], + ); + } +} + +class _RocketRankNavigation extends StatelessWidget { + const _RocketRankNavigation(); + + @override + Widget build(BuildContext context) { + return const Stack( + children: [ + Positioned( + left: 0, + top: 57, + width: _RocketRankLayout.designWidth, + child: Text( + 'Rocket King', + textAlign: TextAlign.center, + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white, + fontSize: 18, + height: 1, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ), + ], + ); + } +} + +class _RocketRankBackButton extends StatelessWidget { + const _RocketRankBackButton(); + + @override + Widget build(BuildContext context) { + return Positioned( + left: 0, + top: 46, + width: 56, + height: 44, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } + }, + child: const Align( + alignment: Alignment.center, + child: Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + size: 24, + ), + ), + ), + ); + } +} + +class _GradientText extends StatelessWidget { + const _GradientText(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return ShaderMask( + shaderCallback: + (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white, Color(0xFFA4FFE3)], + ).createShader(bounds), + blendMode: BlendMode.srcIn, + child: Text( + text, + textAlign: TextAlign.center, + textScaler: TextScaler.noScaling, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + height: 1, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ); + } +} + +class _RocketLevelTabs extends StatelessWidget { + const _RocketLevelTabs({ + required this.selectedIndex, + required this.onChanged, + }); + + final int selectedIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 16), + child: Row( + children: List.generate(_RoomRocketRankPageState._levelCount, ( + index, + ) { + return Padding( + padding: EdgeInsets.only( + right: + index == _RoomRocketRankPageState._levelCount - 1 ? 0 : 7, + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(index), + child: SizedBox( + width: 54, + height: 22, + child: _RocketLevelTab( + label: 'Lv.${index + 1}', + selected: index == selectedIndex, + ), + ), + ), + ); + }), + ), + ), + ); + } +} + +class _RocketLevelTab extends StatelessWidget { + const _RocketLevelTab({required this.label, required this.selected}); + + final String label; + final bool selected; + + @override + Widget build(BuildContext context) { + final color = + selected + ? const Color(0xFF75F3CB) + : Colors.white.withValues(alpha: 0.52); + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + if (selected) + const Positioned(left: 0, top: 8, child: _LevelSlashGroup()), + if (selected) + const Positioned( + right: 0, + top: 8, + child: _LevelSlashGroup(flipped: true), + ), + Text( + label, + textAlign: TextAlign.center, + textScaler: TextScaler.noScaling, + style: TextStyle( + color: color, + fontSize: 12, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ], + ); + } +} + +class _LevelSlashGroup extends StatelessWidget { + const _LevelSlashGroup({this.flipped = false}); + + final bool flipped; + + @override + Widget build(BuildContext context) { + final painter = _LevelSlashPainter(flipped: flipped); + return CustomPaint(size: const Size(12, 6), painter: painter); + } +} + +class _LevelSlashPainter extends CustomPainter { + const _LevelSlashPainter({required this.flipped}); + + final bool flipped; + + @override + void paint(Canvas canvas, Size size) { + final paint = + Paint() + ..color = const Color(0xFF75F3CB) + ..strokeWidth = 1.3 + ..strokeCap = StrokeCap.round; + for (var i = 0; i < 3; i++) { + final x = i * 3.2 + 1; + final start = + flipped ? Offset(size.width - x, 1) : Offset(x, size.height - 1); + final end = + flipped ? Offset(size.width - x - 2.2, 5) : Offset(x + 2.2, 1); + canvas.drawLine(start, end, paint); + } + } + + @override + bool shouldRepaint(covariant _LevelSlashPainter oldDelegate) { + return oldDelegate.flipped != flipped; + } +} + +class _RocketRankListContent extends StatelessWidget { + const _RocketRankListContent({required this.entries}); + + final List entries; + + @override + Widget build(BuildContext context) { + final topEntries = entries.take(3).toList(); + final listEntries = entries.skip(3).toList(); + return Stack( + children: [ + if (topEntries.isNotEmpty) _RankOne(entry: topEntries[0]), + if (topEntries.length > 1) + _SideRank( + entry: topEntries[1], + left: 34, + avatarLeft: 53, + frameAsset: _RocketRankAssets.rankTwoFrame, + ), + if (topEntries.length > 2) + _SideRank( + entry: topEntries[2], + left: 231, + avatarLeft: 250, + frameAsset: _RocketRankAssets.rankThreeFrame, + ), + Positioned( + left: 11, + top: 405, + width: 353, + height: 324, + child: MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: ScrollConfiguration( + behavior: const _NoGlowScrollBehavior(), + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemExtent: 47, + itemCount: listEntries.length, + itemBuilder: (context, index) { + return _RocketRankRow(entry: listEntries[index]); + }, + ), + ), + ), + ), + ], + ); + } +} + +class _RankOne extends StatelessWidget { + const _RankOne({required this.entry}); + + final RoomRocketRankEntry entry; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + left: 106, + top: 219, + width: 162, + height: 46, + child: Image.asset( + _RocketRankAssets.podium, + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + ), + ), + Positioned( + left: 143, + top: 157, + width: 88, + height: 88, + child: _FramedRankAvatar( + entry: entry, + frameAsset: _RocketRankAssets.rankOneFrame, + imageInset: 8.8, + imageSize: 70.4, + ), + ), + Positioned( + left: 82, + top: 267, + width: 211, + child: _RocketSingleLineText( + entry.nickname, + fontSize: 16, + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} + +class _SideRank extends StatelessWidget { + const _SideRank({ + required this.entry, + required this.left, + required this.avatarLeft, + required this.frameAsset, + }); + + final RoomRocketRankEntry entry; + final double left; + final double avatarLeft; + final String frameAsset; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + left: left, + top: 324, + width: 110, + height: 31, + child: Opacity( + opacity: 0.65, + child: Image.asset( + _RocketRankAssets.podium, + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + ), + ), + ), + Positioned( + left: avatarLeft, + top: 272, + width: 72, + height: 72, + child: _FramedRankAvatar( + entry: entry, + frameAsset: frameAsset, + imageInset: 7.2, + imageSize: 57.6, + ), + ), + Positioned( + left: left, + top: 359, + width: 110, + child: _RocketSingleLineText( + entry.nickname, + fontSize: 16, + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} + +class _FramedRankAvatar extends StatelessWidget { + const _FramedRankAvatar({ + required this.entry, + required this.frameAsset, + required this.imageInset, + required this.imageSize, + }); + + final RoomRocketRankEntry entry; + final String frameAsset; + final double imageInset; + final double imageSize; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + left: imageInset, + top: imageInset, + width: imageSize, + height: imageSize, + child: _CircularAvatar(entry: entry), + ), + Positioned.fill( + child: Image.asset( + frameAsset, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + ], + ); + } +} + +class _RocketRankRow extends StatelessWidget { + const _RocketRankRow({required this.entry}); + + final RoomRocketRankEntry entry; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 353, + height: 47, + child: Stack( + children: [ + Positioned( + left: 14, + top: 7, + width: 18, + child: _RocketSingleLineText( + '${entry.rank}', + fontSize: 14, + textAlign: TextAlign.left, + ), + ), + Positioned( + left: 42, + top: 0, + width: 30, + height: 30, + child: _CircularAvatar(entry: entry), + ), + Positioned( + left: 82, + top: 7, + width: 170, + child: _RocketSingleLineText( + entry.nickname, + fontSize: 14, + textAlign: TextAlign.left, + ), + ), + const Positioned( + left: 0, + top: 38, + width: 353, + height: 1, + child: _RocketRankDivider(), + ), + ], + ), + ); + } +} + +class _RocketRankBottomBar extends StatelessWidget { + const _RocketRankBottomBar({required this.entry}); + + final RoomRocketRankEntry entry; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + left: 0, + top: 729, + width: _RocketRankLayout.designWidth, + height: 83, + child: Transform.rotate( + angle: math.pi, + child: Image.asset( + _RocketRankAssets.bottomBar, + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + ), + ), + ), + Positioned( + left: 36, + top: 768, + width: 32, + child: _RocketSingleLineText( + entry.rank > 0 ? '${entry.rank}' : '--', + fontSize: 16, + textAlign: TextAlign.left, + ), + ), + Positioned( + left: 75, + top: 758, + width: 38, + height: 38, + child: _CircularAvatar( + entry: RoomRocketRankEntry( + rank: entry.rank, + nickname: entry.nickname, + userId: entry.userId, + account: entry.account, + avatarUrl: entry.avatarUrl, + avatarAsset: entry.avatarAsset, + ), + ), + ), + Positioned( + left: 122, + top: 768, + width: 190, + child: _RocketSingleLineText( + entry.nickname, + fontSize: 16, + textAlign: TextAlign.left, + ), + ), + ], + ); + } +} + +class _RocketRankEmptyState extends StatelessWidget { + const _RocketRankEmptyState(); + + @override + Widget build(BuildContext context) { + return Positioned( + left: 28, + top: 208, + width: 319, + height: 430, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + _RocketRankAssets.noData, + width: 118, + height: 118, + fit: BoxFit.contain, + ), + const SizedBox(height: 14), + Text( + 'Not rocket-level; no “Rocket King”', + textAlign: TextAlign.center, + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.62), + fontSize: 14, + height: 1.25, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ], + ), + ); + } +} + +class _CircularAvatar extends StatelessWidget { + const _CircularAvatar({required this.entry}); + + final RoomRocketRankEntry entry; + + @override + Widget build(BuildContext context) { + final avatarUrl = entry.avatarUrl?.trim() ?? ''; + final avatarAsset = entry.avatarAsset?.trim() ?? ''; + final image = + avatarUrl.isNotEmpty + ? Image.network( + avatarUrl, + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + errorBuilder: (_, _, _) => const _AvatarPlaceholder(), + ) + : avatarAsset.isNotEmpty + ? Image.asset(avatarAsset, fit: BoxFit.cover) + : const _AvatarPlaceholder(); + return ClipOval(child: image); + } +} + +class _AvatarPlaceholder extends StatelessWidget { + const _AvatarPlaceholder(); + + @override + Widget build(BuildContext context) { + 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: 18), + ); + } +} + +class _RocketSingleLineText extends StatelessWidget { + const _RocketSingleLineText( + this.text, { + required this.fontSize, + required this.textAlign, + }); + + final String text; + final double fontSize; + final TextAlign textAlign; + + @override + Widget build(BuildContext context) { + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: textAlign, + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ); + } +} + +class _RocketRankDivider extends StatelessWidget { + const _RocketRankDivider(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0), + Colors.white.withValues(alpha: 0.28), + Colors.white.withValues(alpha: 0), + ], + stops: const [0, 0.5, 1], + ), + ), + ); + } +} + +class _NoGlowScrollBehavior extends ScrollBehavior { + const _NoGlowScrollBehavior(); + + @override + Widget buildOverscrollIndicator( + BuildContext context, + Widget child, + ScrollableDetails details, + ) { + return child; + } +} + +class _RocketRankLayout { + const _RocketRankLayout._(); + + static const double designWidth = 375; + static const double designHeight = 812; +} + +class _RocketRankAssets { + const _RocketRankAssets._(); + + static const String background = 'sc_images/room/rocket/rocket_rank_bg.png'; + static const String bottomBar = + 'sc_images/room/rocket/bottom_strip_active.png'; + static const String podium = 'sc_images/room/rocket/rocket_stage.png'; + static const String rankOneFrame = + 'sc_images/room/rocket/rocket_rank_frame_1.png'; + static const String rankTwoFrame = + 'sc_images/room/rocket/rocket_rank_frame_2.png'; + static const String rankThreeFrame = + 'sc_images/room/rocket/rocket_rank_frame_3.png'; + static const String noData = 'sc_images/general/sc_no_data.png'; +} diff --git a/lib/modules/room/voice_room_route.dart b/lib/modules/room/voice_room_route.dart index 8d9d90a..3f42d7f 100644 --- a/lib/modules/room/voice_room_route.dart +++ b/lib/modules/room/voice_room_route.dart @@ -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 openRoomRocketRank( + BuildContext context, { + String? roomId, + int initialLevel = 0, + bool rootNavigator = false, + }) { + return Navigator.of(context, rootNavigator: rootNavigator).push( + _buildDarkRoute( + RoomRocketRankPage(roomId: roomId, initialLevel: initialLevel), + settings: RouteSettings(name: roomRocketRank), + ), + ); + } + + static Future openRoomRocketRankOn( + NavigatorState navigator, { + String? roomId, + int initialLevel = 0, + }) { + return navigator.push( + _buildDarkRoute( + 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( diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 157c611..88598fa 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -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? _joinAgoraCompleter; Future? _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 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 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); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 8e00cd2..2cc9ef8 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -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( + 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( + 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 _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 _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"]; diff --git a/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart new file mode 100644 index 0000000..c0a0fa7 --- /dev/null +++ b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart @@ -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 top1; + final List ignite; + final List inRoom; + + List get all => [...top1, ...ignite, ...inRoom]; + + static List _rewardList(dynamic value) { + if (value is! Iterable) { + return const []; + } + 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 podium; + final List records; + final SCRoomRocketKingUserRes? currentUser; + final String emptyReason; + + List get displayRecords { + if (records.isNotEmpty) { + return records; + } + return podium; + } + + static List _kingList(dynamic value) { + if (value is! Iterable) { + return const []; + } + 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 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 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 listFromJson(dynamic value) { + if (value is! Iterable) { + return const []; + } + return value + .map(SCRoomRocketRewardRecordRes.fromJson) + .toList(growable: false); + } +} + +Map _asMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, item) => MapEntry(key.toString(), item)); + } + return const {}; +} + +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; +} diff --git a/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart b/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart index e522539..3ecc795 100644 --- a/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart +++ b/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart @@ -1,145 +1,256 @@ -/// roomId : 0 -/// date : "" -/// level : 0 -/// levelName : "" -/// currentEnergy : 0 -/// maxEnergy : 0 -/// energyPercent : 0.0 -/// status : "" -/// statusDesc : "" -/// totalContributors : 0 -/// launchTime : "" -/// claimExpireTime : "" -/// claimCount : 0 -/// canClaim : false -/// hasClaimed : false -/// myContribution : 0 -/// myContributionRate : 0.0 - -class SCRoomRocketStatusRes { - SCRoomRocketStatusRes({ - String? roomId, - String? date, - num? level, - String? levelName, - int? currentEnergy, - int? maxEnergy, - num? energyPercent, - String? status, - String? statusDesc, - num? totalContributors, - String? launchTime, - String? claimExpireTime, - num? claimCount, - bool? canClaim, - bool? hasClaimed, - num? myContribution, - num? myContributionRate, - }) { - _roomId = roomId; - _date = date; - _level = level; - _levelName = levelName; - _currentEnergy = currentEnergy; - _maxEnergy = maxEnergy; - _energyPercent = energyPercent; - _status = status; - _statusDesc = statusDesc; - _totalContributors = totalContributors; - _claimExpireTime = claimExpireTime; - _claimCount = claimCount; - _canClaim = canClaim; - _hasClaimed = hasClaimed; - _myContribution = myContribution; - _myContributionRate = myContributionRate; - } - - SCRoomRocketStatusRes.fromJson(dynamic json) { - _roomId = json['roomId']; - _date = json['date']; - _level = json['level']; - _levelName = json['levelName']; - _currentEnergy = json['currentEnergy']; - _maxEnergy = json['maxEnergy']; - _energyPercent = json['energyPercent']; - _status = json['status']; - _statusDesc = json['statusDesc']; - _totalContributors = json['totalContributors']; - _claimExpireTime = json['claimExpireTime']; - _claimCount = json['claimCount']; - _canClaim = json['canClaim']; - _hasClaimed = json['hasClaimed']; - _myContribution = json['myContribution']; - _myContributionRate = json['myContributionRate']; - } - - String? _roomId; - String? _date; - num? _level; - String? _levelName; - int? _currentEnergy; - int? _maxEnergy; - num? _energyPercent; - String? _status; - String? _statusDesc; - num? _totalContributors; - String? _claimExpireTime; - num? _claimCount; - bool? _canClaim; - bool? _hasClaimed; - num? _myContribution; - num? _myContributionRate; - - String? get roomId => _roomId; - - String? get date => _date; - - num? get level => _level; - - String? get levelName => _levelName; - - int? get currentEnergy => _currentEnergy; - - int? get maxEnergy => _maxEnergy; - - num? get energyPercent => _energyPercent; - - String? get status => _status; - - String? get statusDesc => _statusDesc; - - num? get totalContributors => _totalContributors; - - String? get claimExpireTime => _claimExpireTime; - - num? get claimCount => _claimCount; - - bool? get canClaim => _canClaim; - - bool? get hasClaimed => _hasClaimed; - - num? get myContribution => _myContribution; - - num? get myContributionRate => _myContributionRate; - - Map toJson() { - final map = {}; - map['roomId'] = _roomId; - map['date'] = _date; - map['level'] = _level; - map['levelName'] = _levelName; - map['currentEnergy'] = _currentEnergy; - map['maxEnergy'] = _maxEnergy; - map['energyPercent'] = _energyPercent; - map['status'] = _status; - map['statusDesc'] = _statusDesc; - map['totalContributors'] = _totalContributors; - map['claimExpireTime'] = _claimExpireTime; - map['claimCount'] = _claimCount; - map['canClaim'] = _canClaim; - map['hasClaimed'] = _hasClaimed; - map['myContribution'] = _myContribution; - map['myContributionRate'] = _myContributionRate; - return map; - } -} +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; + +/// roomId : 0 +/// date : "" +/// level : 0 +/// levelName : "" +/// currentEnergy : 0 +/// maxEnergy : 0 +/// energyPercent : 0.0 +/// status : "" +/// statusDesc : "" +/// totalContributors : 0 +/// launchTime : "" +/// claimExpireTime : "" +/// claimCount : 0 +/// canClaim : false +/// hasClaimed : false +/// myContribution : 0 +/// myContributionRate : 0.0 + +class SCRoomRocketStatusRes { + SCRoomRocketStatusRes({ + String? roomId, + String? date, + num? level, + String? levelName, + int? currentEnergy, + int? maxEnergy, + num? energyPercent, + String? status, + String? statusDesc, + num? totalContributors, + String? launchTime, + String? claimExpireTime, + num? claimCount, + bool? canClaim, + bool? hasClaimed, + num? myContribution, + num? myContributionRate, + int? roundNo, + bool? displayRound, + int? currentLevel, + int? needEnergy, + num? displayPercent, + bool? shake, + List? levels, + SCRoomRocketRewardPreviewRes? rewardPreview, + List? rocketKings, + }) { + _roomId = roomId; + _date = date; + _level = level; + _levelName = levelName; + _currentEnergy = currentEnergy; + _maxEnergy = maxEnergy; + _energyPercent = energyPercent; + _status = status; + _statusDesc = statusDesc; + _totalContributors = totalContributors; + _claimExpireTime = claimExpireTime; + _claimCount = claimCount; + _canClaim = canClaim; + _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'] ?? 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 = _intValue(json['currentEnergy']); + _maxEnergy = _intValue(json['maxEnergy']) ?? _needEnergy; + _energyPercent = _numValue(json['energyPercent']) ?? _displayPercent; + _status = json['status']; + _statusDesc = json['statusDesc']; + _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; + String? _date; + num? _level; + String? _levelName; + int? _currentEnergy; + int? _maxEnergy; + num? _energyPercent; + String? _status; + String? _statusDesc; + num? _totalContributors; + String? _claimExpireTime; + num? _claimCount; + bool? _canClaim; + bool? _hasClaimed; + num? _myContribution; + num? _myContributionRate; + int? _roundNo; + bool? _displayRound; + int? _currentLevel; + int? _needEnergy; + num? _displayPercent; + bool? _shake; + List? _levels; + SCRoomRocketRewardPreviewRes? _rewardPreview; + List? _rocketKings; + + String? get roomId => _roomId; + + String? get date => _date; + + num? get level => _level; + + String? get levelName => _levelName; + + int? get currentEnergy => _currentEnergy; + + int? get maxEnergy => _maxEnergy; + + num? get energyPercent => _energyPercent; + + String? get status => _status; + + String? get statusDesc => _statusDesc; + + num? get totalContributors => _totalContributors; + + String? get claimExpireTime => _claimExpireTime; + + num? get claimCount => _claimCount; + + bool? get canClaim => _canClaim; + + bool? get hasClaimed => _hasClaimed; + + num? get myContribution => _myContribution; + + num? get myContributionRate => _myContributionRate; + + int? get roundNo => _roundNo; + + bool? get displayRound => _displayRound; + + int? get currentLevel => _currentLevel; + + int? get needEnergy => _needEnergy; + + num? get displayPercent => _displayPercent; + + bool? get shake => _shake; + + List? get levels => _levels; + + SCRoomRocketRewardPreviewRes? get rewardPreview => _rewardPreview; + + List? get rocketKings => _rocketKings; + + Map toJson() { + final map = {}; + map['roomId'] = _roomId; + map['date'] = _date; + map['level'] = _level; + map['levelName'] = _levelName; + map['currentEnergy'] = _currentEnergy; + map['maxEnergy'] = _maxEnergy; + map['energyPercent'] = _energyPercent; + map['status'] = _status; + map['statusDesc'] = _statusDesc; + map['totalContributors'] = _totalContributors; + map['claimExpireTime'] = _claimExpireTime; + map['claimCount'] = _claimCount; + map['canClaim'] = _canClaim; + map['hasClaimed'] = _hasClaimed; + map['myContribution'] = _myContribution; + map['myContributionRate'] = _myContributionRate; + map['roundNo'] = _roundNo; + map['displayRound'] = _displayRound; + map['currentLevel'] = _currentLevel; + map['needEnergy'] = _needEnergy; + map['displayPercent'] = _displayPercent; + map['shake'] = _shake; + return map; + } + + static int? _intValue(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString().trim() ?? ''); + } + + static num? _numValue(dynamic value) { + if (value is num) { + return value; + } + return num.tryParse(value?.toString().trim() ?? ''); + } + + static bool? _boolValue(dynamic value) { + if (value is bool) { + return value; + } + final text = value?.toString().trim().toLowerCase(); + if (text == 'true' || text == '1') { + return true; + } + if (text == 'false' || text == '0') { + return false; + } + return null; + } + + static List? _levelList(dynamic value) { + if (value is! Iterable) { + return null; + } + return value.map(SCRoomRocketLevelRes.fromJson).toList(growable: false); + } + + static List? _kingList(dynamic value) { + if (value is! Iterable) { + return null; + } + return value.map(SCRoomRocketKingUserRes.fromJson).toList(growable: false); + } +} diff --git a/lib/shared/business_logic/repositories/room_repository.dart b/lib/shared/business_logic/repositories/room_repository.dart index 8f6378e..0b7961d 100644 --- a/lib/shared/business_logic/repositories/room_repository.dart +++ b/lib/shared/business_logic/repositories/room_repository.dart @@ -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 rocketStatus(String roomId); + ///查询火箭王榜单 + Future roomRocketKing( + String roomId, { + int level = 1, + int roundNo = 0, + int cursor = 1, + int limit = 100, + }); + + ///查询中奖弹窗 + Future roomRocketRewardPopups(String roomId); + + ///确认中奖弹窗已读 + Future ackRoomRocketRewardPopups(List rewardRecordIds); + + ///查询我的中奖记录 + Future roomRocketRewardRecords( + String roomId, { + int cursor = 1, + int limit = 20, + }); + + ///查询火箭规则说明 + Future roomRocketRule(); + ///查询火箭配置 Future> rocketConfigEnabled(); diff --git a/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart new file mode 100644 index 0000000..1fa75ae --- /dev/null +++ b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart @@ -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 _asMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, item) => MapEntry(key.toString(), item)); + } + return const {}; +} + +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; +} diff --git a/lib/shared/data_sources/models/message/sc_floating_message.dart b/lib/shared/data_sources/models/message/sc_floating_message.dart index df38817..e3b69b7 100644 --- a/lib/shared/data_sources/models/message/sc_floating_message.dart +++ b/lib/shared/data_sources/models/message/sc_floating_message.dart @@ -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; diff --git a/lib/shared/data_sources/sources/local/floating_screen_manager.dart b/lib/shared/data_sources/sources/local/floating_screen_manager.dart index df4fb58..fe0896f 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -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) { diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 9413d6a..81e2e03 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -1,1137 +1,1282 @@ -import 'dart:convert'; - -import 'package:flutter/cupertino.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'; -import 'package:yumi/shared/business_logic/models/res/sc_is_follow_room_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_mic_go_up_res.dart'; -import 'package:yumi/shared/business_logic/models/res/mic_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_room_contribute_level_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_room_join_black_list_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_detail_res.dart'; -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_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'; -import 'package:yumi/shared/business_logic/models/res/user_count_guard_res.dart'; -import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; -import 'package:provider/provider.dart'; - -import 'package:yumi/shared/business_logic/models/res/sc_banner_leaderboard_res.dart'; -import 'package:yumi/shared/business_logic/models/res/gift_backpack_res.dart'; -import 'package:yumi/shared/business_logic/models/res/gift_by_group_res.dart'; -import 'package:yumi/shared/business_logic/models/res/login_res.dart'; -import 'package:yumi/shared/business_logic/models/res/my_room_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_room_emoji_res.dart'; -import 'package:yumi/shared/business_logic/models/res/room_gift_rank_res.dart'; -import 'package:yumi/shared/business_logic/models/res/room_member_res.dart'; -import 'package:yumi/shared/business_logic/models/res/room_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_config_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_red_packet_sent_record_res.dart'; -import 'package:yumi/shared/business_logic/models/res/room_user_card_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_violation_handle_res.dart'; -import 'package:yumi/shared/business_logic/repositories/room_repository.dart'; -import 'package:yumi/services/audio/rtm_manager.dart'; -import 'package:yumi/shared/data_sources/sources/remote/net/api.dart'; -import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart'; -import 'package:yumi/shared/tools/sc_room_profile_cache.dart'; - -class SCChatRoomRepository implements SocialChatRoomRepository { - static SCChatRoomRepository? _instance; - - SCChatRoomRepository._internal(); - - factory SCChatRoomRepository() { - return _instance ??= SCChatRoomRepository._internal(); - } - - void _giftRepoLog(String message) {} - - Future _traceRoomRedPacketApi( - String method, - String path, - Future Function() request, { - dynamic data, - dynamic query, - }) async { - final startedAt = DateTime.now(); - try { - final result = await request(); - return result; - } catch (error, stackTrace) { - _logRoomRedPacketApiError(method, path, error, stackTrace); - rethrow; - } - } - - void _logRoomRedPacketApiError( - String method, - String path, - Object error, - StackTrace stackTrace, - ) { - if (error is DioException) { - final request = error.requestOptions; - } else {} - } - - String _debugJson(dynamic value) { - if (value == null) { - return '-'; - } - try { - return jsonEncode(value); - } catch (_) { - return value.toString(); - } - } - - String _debugResult(dynamic value) { - if (value == null) { - return 'null'; - } - if (value is List) { - return 'List(length=${value.length})'; - } - try { - final dynamic json = value.toJson(); - return '${value.runtimeType} ${_debugJson(json)}'; - } catch (_) { - return value.runtimeType.toString(); - } - } - - dynamic _jsonInt64(String value) { - final normalized = value.trim(); - return int.tryParse(normalized) ?? normalized; - } - - bool _isBrokenLocalMediaUrl(String? url) { - return (url ?? "").contains("/external/oss/local/"); - } - - bool _shouldHydrateRoomProfile(String? roomId, String? roomCover) { - return (roomId ?? "").trim().isNotEmpty && - ((roomCover ?? "").trim().isEmpty || _isBrokenLocalMediaUrl(roomCover)); - } - - Future _hydrateRoomRes(SocialChatRoomRes room) async { - final cachedRoom = SCRoomProfileCache.applyToSocialChatRoomRes(room); - if (!_shouldHydrateRoomProfile(cachedRoom.id, cachedRoom.roomCover)) { - return cachedRoom; - } - final specificRoom = await specific(cachedRoom.id ?? ""); - return SCRoomProfileCache.applyToSocialChatRoomRes( - cachedRoom.copyWith( - roomCover: SCRoomProfileCache.preferNonEmpty( - specificRoom.roomCover, - cachedRoom.roomCover, - ), - roomBackground: SCRoomProfileCache.preferNonEmpty( - specificRoom.roomBackground, - cachedRoom.roomBackground, - ), - roomName: SCRoomProfileCache.preferNonEmpty( - specificRoom.roomName, - cachedRoom.roomName, - ), - roomDesc: SCRoomProfileCache.preferNonEmpty( - specificRoom.roomDesc, - cachedRoom.roomDesc, - ), - ), - ); - } - - ///room/live-voice/discovery - @override - Future> discovery({bool? allRegion}) async { - Map queryParams = {}; - if (allRegion != null) { - queryParams["allRegion"] = allRegion; - } - final result = await http.get( - "363603111e51beac2d183014dd29b81ced652239ec13178acd7001096cab7429", - queryParams: queryParams, - fromJson: - (json) => - (json as List).map((e) => SocialChatRoomRes.fromJson(e)).toList(), - ); - return Future.wait(result.map(_hydrateRoomRes)); - } - - ///live/mic/list - @override - Future> micList( - String roomId, { - bool silentErrorToast = false, - }) async { - Map queryParams = {}; - queryParams["roomId"] = roomId; - final result = await http.get( - "b1d6a1284f5ddeaa1cbb1430f2991e8a", - queryParams: queryParams, - extra: - silentErrorToast - ? const {BaseNetworkClient.silentErrorToastKey: true} - : null, - fromJson: - (json) => (json as List).map((e) => MicRes.fromJson(e)).toList(), - ); - return result; - } - - ///room/profile/specific - @override - Future specific(String roomId) async { - Map queryParams = {}; - queryParams["roomId"] = roomId; - final result = await http.get( - "08c7cb961a83d24c9e0d4b11d7af3f0a8f2fe5ccf18a5ca6a0caf80ecefaeb02", - queryParams: queryParams, - fromJson: (json) => MyRoomRes.fromJson(json), - ); - await SCRoomProfileCache.saveRoomProfile( - roomId: result.id ?? roomId, - roomCover: result.roomCover, - roomBackground: result.roomBackground, - roomName: result.roomName, - roomDesc: result.roomDesc, - ); - return SCRoomProfileCache.applyToMyRoomRes(result); - } - - ///room/relation/status - @override - Future isFollowRoom(String roomId) async { - Map queryParams = {}; - queryParams["roomId"] = roomId; - final result = await http.get( - "1c9e38f372be8ae5afde3a37710c24815dee9b62b097054c3e40e333e7747681", - queryParams: queryParams, - fromJson: (json) => SCIsFollowRoomRes.fromJson(json), - ); - return result; - } - - ///room/contribution/level - @override - Future contributeLevel(String roomId) async { - Map queryParams = {}; - queryParams["roomId"] = roomId; - final result = await http.get( - "730cfe8ab28a5bb6b75adb2d29320bd85285ddc5a507f997b1c6a23980d22a70", - queryParams: queryParams, - fromJson: (json) => SCRoomContributeLevelRes.fromJson(json), - ); - return result; - } - - ///activity/room-contribution-activity - @override - Future roomContributionActivity( - String roomId, - ) async { - Map queryParams = {}; - queryParams["roomId"] = roomId; - final result = await http.get( - "fe8a114454bb0a64c51b5a2840b89b9caad8e1b76b3e1c570f21d856505cee7d1621a5cc87df186c3e223af28d3b4455", - queryParams: queryParams, - fromJson: (json) => SCRoomContributeLevelRes.fromJson(json), - ); - return result; - } - - ///live/mic/go-up - @override - Future micGoUp( - String roomId, - num mickIndex, { - String? eventType, - String? inviterId, - }) async { - Map params = {}; - params["roomId"] = roomId; - params["mickIndex"] = mickIndex; - if (eventType != null) { - params["eventType"] = eventType; - } - if (inviterId != null) { - params["inviterId"] = inviterId; - } - final result = await http.post( - "6fb96d680637b8e931b962f6e05260b0", - data: params, - fromJson: (json) => SCMicGoUpRes.fromJson(json), - ); - return result; - } - - ///live/mic/go-down - @override - Future micGoDown(String roomId, num mickIndex) async { - final result = await http.post( - "11335ecd55d99c9100f5cd070e5b73f1574aff257ce7e668d08f4caccd1c6232", - data: {"roomId": roomId, "mickIndex": mickIndex}, - allowNullBody: true, - fromJson: (json) => null, - ); - return result; - } - - ///live/mic/lock - @override - Future micLock(String roomId, num mickIndex, bool lock) async { - final result = await http.post( - "70ffb9681e0103463b1faf883935e55d", - data: {"roomId": roomId, "mickIndex": mickIndex, "lock": lock}, - allowNullBody: true, - fromJson: (json) => null, - ); - return result; - } - - ///live/mic/mute - @override - Future micMute(String roomId, num mickIndex, bool mute) async { - final result = await http.post( - "97927b83eb6b9fccda567f3bf12cbf49", - data: {"roomId": roomId, "mickIndex": mickIndex, "mute": mute}, - fromJson: (json) => json as bool, - ); - return result; - } - - ///live/user/list - @override - Future> roomOnlineUsers( - String roomId, { - bool silentErrorToast = false, - }) async { - final result = await http.get( - "a01ade8fbb604c0904557a9483fcc4d9", - queryParams: {"roomId": roomId}, - extra: - silentErrorToast - ? const {BaseNetworkClient.silentErrorToastKey: true} - : null, - fromJson: - (json) => - (json as List) - .map((e) => socialChatUserProfileFromJsonWithVipHint(e)!) - .toList(), - ); - return result; - } - - ///user/user-profile/card - @override - Future roomUserCard(String roomId, String userId) async { - final result = await http.get( - "2fa1d4f56bb726558904ce2a50b83f967732a24a30dccae0c57cf03f27319ea4", - queryParams: {"roomId": roomId, "userId": userId}, - fromJson: (json) => RoomUserCardRes.fromJson(json), - ); - return result; - } - - ///gift/list - @override - Future> giftList() async { - final result = await http.get>( - "3a613b7450f8fd9082b988bd21df454d", - fromJson: - (json) => - (json as List).map((e) => SocialChatGiftRes.fromJson(e)).toList(), - ); - return result; - } - - ///gift/group-tab - @override - Future> giftListByGroup() async { - final result = await http.get>( - "bfbb6d98524f1e8aecf695e32ed24c2f", - fromJson: - (json) => - (json as List).map((e) => GiftByGroupRes.fromJson(e)).toList(), - ); - return result; - } - - ///gift/batch - @override - Future giveGift( - List acceptUserIds, - String giftId, - num quantity, - bool checkCombo, { - String? roomId, - SCGiveAwayGiftRoomAcceptsCmd? accepts, - String? dynamicContentId, - }) async { - Map params = {}; - if (roomId != null) { - params["roomId"] = roomId; - } - params["giftId"] = giftId; - params["acceptUserIds"] = acceptUserIds; - params["quantity"] = quantity; - params["checkCombo"] = checkCombo; - if (accepts != null) { - params["accepts"] = accepts.toJson(); - } - if (dynamicContentId != null) { - params["dynamicContentId"] = dynamicContentId; - } - - final result = await http.post( - "daa4f379ff3e429e55c318b6af7291e8", - data: params, - extra: const {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => json as double, - ); - return result; - } - - ///live/mic/kill - @override - Future micKill(String roomId, num mickIndex) async { - final result = await http.post( - "e50ed2f3406bc9421f3271f8be367d9b", - data: {"roomId": roomId, "mickIndex": mickIndex}, - fromJson: (json) => json as bool, - ); - return result; - } - - ///sys/reported - @override - Future reported( - String reportUserId, - String reportedUserId, - num reportType, { - String? reportedContent, - num? relatedId, - String? imageUrls, - String? videoUrls, - }) async { - Map params = {}; - params["reportUserId"] = reportUserId; - params["reportedUserId"] = reportedUserId; - params["reportType"] = reportType; - if (reportedContent != null && reportedContent.isNotEmpty) { - params["reportedContent"] = reportedContent; - } - if (relatedId != null) { - params["relatedId"] = relatedId; - } - if (imageUrls != null && imageUrls.isNotEmpty) { - params["imageUrls"] = imageUrls; - } - if (videoUrls != null && videoUrls.isNotEmpty) { - params["videoUrls"] = videoUrls; - } - final result = await http.post( - "552d752693c91a4a3dd9723634dff21f", - data: params, - fromJson: (json) => json as bool, - ); - return result; - } - - ///room/live-voice/search - @override - Future> searchRoom(String roomAccount) async { - final result = await http.get>( - "363603111e51beac2d183014dd29b81cce9dda0f9cd8c267f6e4fd3f05bda090", - queryParams: {"roomAccount": roomAccount}, - fromJson: - (json) => - (json as List).map((e) => SocialChatRoomRes.fromJson(e)).toList(), - ); - return Future.wait(result.map(_hydrateRoomRes)); - } - - ///room/blacklist/join - @override - Future joinBlacklist( - String roomId, - String userId, - num outTime, - ) async { - final result = await http.post( - "fe07c4b4a0ab1a38a5674c77444296f1a31f65259a63574dbbfbfe543f1b99a6", - data: {"roomId": roomId, "userId": userId, "outTime": outTime}, - fromJson: (json) => SCRoomJoinBlackListRes.fromJson(json), - ); - return result; - } - - ///room/blacklist/remove - @override - Future removeBlacklist(String roomId, String userId) async { - final result = await http.post( - "68900c787a89c1092b21ee1a610ef323380d4f62784dacdb4e3d4e1f60770a39", - data: {"roomId": roomId, "userId": userId}, - allowNullBody: true, - fromJson: (json) => null, - ); - return result; - } - - ///user/count/guard - @override - Future> userCountGuard(String userId) async { - final result = await http.get>( - "cef5832baa42d4adb30f4b10bef0887e574aff257ce7e668d08f4caccd1c6232", - queryParams: {"userId": userId}, - fromJson: - (json) => - (json as List).map((e) => UserCountGuardRes.fromJson(e)).toList(), - ); - return result; - } - - ///material/emoji/all - @override - Future> emojiAll() async { - final result = await http.get>( - "1d2546ea50639e65f04d029dd0a9655eda4aeb3a4bd182b663d60d87cb3f2938", - fromJson: - (json) => - (json as List).map((e) => SCRoomEmojiRes.fromJson(e)).toList(), - ); - return result; - } - - ///room/member/change-role - @override - Future changeRoomRole( - String roomId, - String userType, - String changeUserId, - String operatorUserId, - String reason, - ) async { - final result = await http.put( - "39b2c4ffda0682c126435fc37480a0961be75ad669bd0bf28d4e875a45934dfe", - data: { - "roomId": roomId, - "userType": userType, - "changeUserId": changeUserId, - "operatorUserId": operatorUserId, - "reason": reason, - }, - fromJson: (json) => json as bool, - ); - return result; - } - - ///room/setting/locked - @override - Future roomLocked(String roomId, String password) async { - final result = await http.put( - "e9531da4dd5c83bd5386366519ac6d0b727c997eb7a1adb8323f850b6c541f3b", - data: {"roomId": roomId, "password": password}, - fromJson: (json) => json as bool, - ); - return result; - } - - ///room/setting/unlocked - @override - Future roomUnlocked(String roomId) async { - final result = await http.post( - "d8e78b8fb4cf9f0d41716d9978235334d9382f6a419ce06c2dab0b362c318a5d", - data: {"roomId": roomId}, - fromJson: (json) => json as bool, - ); - return result; - } - - ///room/setting - @override - Future updateRoomSetting( - String roomId, - String roomAcount, - BuildContext context, { - bool? touristMike, - String? takeMicRole, - bool? touristMsg, - String? joinGolds, - num? mikeSize, - bool? allowMusic, - bool? adminLockSeat, - bool? showHeartbeat, - bool? openKtvMode, - String? roomSpecialMikeType, - }) async { - final rtmProvider = Provider.of(context, listen: false); - Map params = {}; - params["roomId"] = roomId; - if (touristMike != null) { - params["touristMike"] = touristMike; - } - if (takeMicRole != null) { - params["takeMicRole"] = takeMicRole; - } - if (touristMsg != null) { - params["touristMsg"] = touristMsg; - } - if (joinGolds != null) { - params["joinGolds"] = joinGolds; - } - if (mikeSize != null) { - params["mikeSize"] = mikeSize; - } - if (allowMusic != null) { - params["allowMusic"] = allowMusic; - } - if (adminLockSeat != null) { - params["adminLockSeat"] = adminLockSeat; - } - if (showHeartbeat != null) { - params["showHeartbeat"] = showHeartbeat; - } - if (openKtvMode != null) { - params["openKtvMode"] = openKtvMode; - } - if (roomSpecialMikeType != null) { - params["roomSpecialMikeType"] = roomSpecialMikeType; - } - final result = await http.put( - "cd447427897a27ec5a710ae5861907c0", - data: params, - fromJson: (json) => json as bool, - ); - - rtmProvider.dispatchMessage( - Msg( - groupId: roomAcount, - msg: roomId, - type: SCRoomMsgType.roomSettingUpdate, - ), - addLocal: false, - ); - return result; - } - - ///room/member - @override - Future> roomMember( - String roomId, { - String? lastId, - }) async { - Map parm = {}; - parm["roomId"] = roomId; - if (lastId != null) { - parm["lastId"] = lastId; - } - final result = await http.get>( - "76393fe91160b664a81fdc0545a586ef", - queryParams: parm, - fromJson: - (json) => - (json as List) - .map((e) => SocialChatRoomMemberRes.fromJson(e)) - .toList(), - ); - return result; - } - - ///room/contribution/rank - @override - Future> roomContributionRank( - String roomId, - String dataType, { - num size = 10, - }) async { - Map parm = {}; - parm["roomId"] = roomId; - parm["dataType"] = dataType; - parm["size"] = size; - final result = await http.get>( - "730cfe8ab28a5bb6b75adb2d29320bd80ed2ff7efd67897d5b80fd620a1b0676", - queryParams: parm, - fromJson: - (json) => - (json as List).map((e) => RoomGiftRankRes.fromJson(e)).toList(), - ); - return result; - } - - ///room/setting/kick-off-microphone - @override - Future kickOffMicrophone(String roomId, String kickUserId) async { - final result = await http.post( - "a4465894e21be08d833ef701566ffe1b6a835338046b35fb94271445c996f6e3574aff257ce7e668d08f4caccd1c6232", - data: {"roomId": roomId, "kickUserId": kickUserId}, - fromJson: (json) => json as bool, - ); - return result; - } - - ///gift/give/lucky-gift - @override - Future giveLuckyGift( - List acceptUserIds, - String giftId, - num quantity, - bool checkCombo, { - String? roomId, - List? accepts, - Object? gameId, - String? dynamicContentId, - String? songId, - }) async { - Map params = {}; - if (roomId != null) { - params["roomId"] = roomId; - } - params["giftId"] = giftId; - params["acceptUserIds"] = acceptUserIds; - params["quantity"] = quantity; - params["checkCombo"] = checkCombo; - params["gameId"] = gameId; - params["accepts"] = - (accepts ?? const []) - .map((item) => item.toJson()) - .toList(); - params["dynamicContentId"] = dynamicContentId; - params["songId"] = songId; - - _giftRepoLog( - 'request giveLuckyGift endpoint=/gift/give/lucky-gift ' - 'giftId=$giftId ' - 'roomId=$roomId ' - 'quantity=$quantity ' - 'checkCombo=$checkCombo ' - 'gameId=$gameId ' - 'dynamicContentId=$dynamicContentId ' - 'songId=$songId ' - 'acceptUserIds=${acceptUserIds.join(",")} ' - 'payload=$params', - ); - final result = await http.post( - "6ddf7ebecfa97adf23c6303877c7763ce591ff710a7abc4fe1e335d16efee21b", - data: params, - extra: const {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => json as double, - ); - _giftRepoLog( - 'response giveLuckyGift endpoint=/gift/give/lucky-gift ' - 'giftId=$giftId ' - 'roomId=$roomId ' - 'balance=$result', - ); - return result; - } - - ///activity/leaderboard/appLeaderboard - @override - Future appLeaderboard() async { - final result = await http.get( - "04f600f838ff20e7f794d3b555103541adda7fb851a14e1c733d58bc5cd3a23b343860371cb9a34e6cdb9005c91c78a9", - fromJson: (json) => SCBannerLeaderboardRes.fromJson(json), - ); - return result; - } - - ///gift/backpack - @override - Future> giftBackpack() async { - final result = await http.get>( - "48ab0afa0e4305070eaa82edc3fc3996", - fromJson: - (json) => - (json as List) - .map((e) => SocialChatGiftBackpackRes.fromJson(e)) - .toList(), - ); - return result; - } - - ///api/rocket/status - @override - Future rocketStatus(String roomId) async { - Map params = {}; - params["roomId"] = roomId; - final result = await http.get( - "1b650f6fc863c8c86ec8d921340a65ccba2fafbaede21817c8d7e89774331852", - queryParams: params, - fromJson: (json) => SCRoomRocketStatusRes.fromJson(json), - ); - return result; - } - - ///api/rocket/config/enabled - @override - Future> rocketConfigEnabled() async { - final result = await http.get>( - "09620f7f773a75af633e034b80393e0ee8adb46dafdd3600f74d3c85e2c07e4f", - fromJson: - (json) => - (json as List) - .map((e) => SCRoomRocketConfigRes.fromJson(e)) - .toList(), - ); - return result; - } - - ///api/rocket/claim - @override - Future rocketClaim(String roomId) async { - final result = await http.post( - "4c083b13f2be2a26641018619bdc0347574aff257ce7e668d08f4caccd1c6232", - data: {"roomId": roomId}, - fromJson: (json) => json as bool, - ); - return result; - } - - ///room-violation/handle - @override - Future roomViolationHandle( - String roomId, - int violationType, - int operationType, - String description, { - List? imageUrls, - }) async { - Map parm = {}; - parm["roomId"] = roomId; - parm["violationType"] = violationType; - parm["operationType"] = operationType; - parm["description"] = description; - if (imageUrls != null && imageUrls.isNotEmpty) { - parm["screenshotUrls"] = imageUrls; - } - final result = await http.post( - "fc47c4e6e3ba228391cd35bf99f301206f894acdcdbac7ccb7e88e9d69ee3eff", - data: parm, - fromJson: (json) => SCViolationHandleRes.fromJson(json), - ); - return result; - } - - ///room/red-packet/list - @override - Future> roomRedPacketList( - String roomId, - int current, { - int size = 20, - }) async { - Map parm = {}; - parm["roomId"] = roomId; - parm["current"] = current; - parm["size"] = size; - const path = - "318de84592dca60c287ba2f54d60756abc4a9e8c4704c0f73c085e7ab50e7fdb"; - return _traceRoomRedPacketApi( - 'GET', - path, - () => http.get>( - path, - queryParams: parm, - fromJson: - (json) => - (json as List) - .map((e) => SCRoomRedPacketListRes.fromJson(e)) - .toList(), - ), - query: parm, - ); - } - - ///room/red-packet/create - @override - Future roomSendRedPacket( - String roomId, - String totalAmount, - String totalCount, - String expireMinutes, { - String packetMode = 'DELAYED', - }) async { - Map parm = {}; - parm["requestId"] = - "room-red-packet-${DateTime.now().microsecondsSinceEpoch}"; - parm["roomId"] = _jsonInt64(roomId); - parm["packetMode"] = packetMode; - parm["totalAmount"] = _jsonInt64(totalAmount); - parm["totalCount"] = _jsonInt64(totalCount); - const path = "/go/app/voice-room/red-packet/send"; - return _traceRoomRedPacketApi( - 'POST', - path, - () => http.post( - path, - data: parm, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketListRes.fromJson(json), - ), - data: parm, - ); - } - - ///room/red-packet/grab - @override - Future roomRedPacketGrab(String packetId) async { - Map parm = {}; - parm["packetNo"] = packetId; - const path = "/go/app/voice-room/red-packet/claim"; - return _traceRoomRedPacketApi( - 'POST', - path, - () => http.post( - path, - data: parm, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketGrabRes.fromJson(json), - ), - data: parm, - ); - } - - ///room/red-packet/detail - @override - Future roomRedPacketDetail(String packetId) async { - Map parm = {}; - parm["packetNo"] = packetId; - const path = "/go/app/voice-room/red-packet/detail"; - return _traceRoomRedPacketApi( - 'GET', - path, - () => http.get( - path, - queryParams: parm, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketDetailRes.fromJson(json), - ), - query: parm, - ); - } - - @override - Future roomRedPacketConfig() async { - const path = "/go/app/voice-room/red-packet/config"; - return _traceRoomRedPacketApi( - 'GET', - path, - () => http.get( - path, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketConfigRes.fromJson(json), - ), - ); - } - - @override - Future roomRedPacketImGroup() async { - const path = "/go/app/voice-room/red-packet/im-group"; - return _traceRoomRedPacketApi( - 'GET', - path, - () => http.get( - path, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketImGroupRes.fromJson(json), - ), - ); - } - - @override - Future roomRedPacketPresenceHeartbeat( - String roomId, - ) async { - const path = "/go/app/voice-room/red-packet/presence/heartbeat"; - final data = {"roomId": _jsonInt64(roomId)}; - return _traceRoomRedPacketApi( - 'POST', - path, - () => http.post( - path, - data: data, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json), - ), - data: data, - ); - } - - @override - Future roomRedPacketPresenceLeave( - String roomId, - ) async { - const path = "/go/app/voice-room/red-packet/presence/leave"; - final data = {"roomId": _jsonInt64(roomId)}; - return _traceRoomRedPacketApi( - 'POST', - path, - () => http.post( - path, - data: data, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json), - ), - data: data, - ); - } - - @override - Future> roomRedPacketSentRecords({ - int cursor = 1, - int limit = 20, - }) async { - const path = "/go/app/voice-room/red-packet/sent-records"; - final query = {"cursor": cursor, "limit": limit}; - return _traceRoomRedPacketApi( - 'GET', - path, - () => http.get>( - path, - queryParams: query, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: - (json) => - _redPacketRecordListBody( - json, - ).map((e) => SCRoomRedPacketSentRecordRes.fromJson(e)).toList(), - ), - query: query, - ); - } - - @override - Future roomRedPacketClaimRecords( - String packetNo, - ) async { - const path = "/go/app/voice-room/red-packet/claim-records"; - final query = {"packetNo": packetNo}; - return _traceRoomRedPacketApi( - 'GET', - path, - () => http.get( - path, - queryParams: query, - extra: {BaseNetworkClient.silentErrorToastKey: true}, - fromJson: (json) => SCRoomRedPacketClaimRecordsRes.fromJson(json), - ), - query: query, - ); - } - - List _redPacketRecordListBody(dynamic json) { - if (json is List) { - return json; - } - if (json is Map) { - for (final key in const ['records', 'list', 'items']) { - final value = json[key]; - if (value is List) { - return value; - } - } - } - return const []; - } - - ///activity/room-contribution-activity/room-reward/info - @override - Future roomRewardInfo(String roomId) async { - Map parm = {}; - parm["roomId"] = roomId; - final result = await http.get( - "fe8a114454bb0a64c51b5a2840b89b9caad8e1b76b3e1c570f21d856505cee7ddf616bd4bdc133af05422ff3f7becc575ad9f024e25ab8275b5792a94159a781", - queryParams: parm, - fromJson: (json) => SCRoomRewardInfoRes.fromJson(json), - ); - return result; - } - - ///activity/room-contribution-activity/room-reward/receive - @override - Future roomRewardReceive(String roomId) async { - Map parm = {}; - parm["roomId"] = roomId; - final result = await http.post( - "1224764475bbd027233e79b81fbdbbf8b25565bc1234221b5e7f9651e0d97576fbcc314a4dd141539dc44cd84f2f13e4c35b35e57872c47078faccd94224944e", - data: parm, - fromJson: (json) => json as bool, - ); - return result; - } - - ///room/red-packet/create-platform - @override - Future sendPlatformRedPack( - String roomId, - String totalAmount, - String totalCount, - String expireMinutes, - ) async { - Map parm = {}; - parm["roomId"] = roomId; - parm["totalAmount"] = totalAmount; - parm["totalCount"] = totalCount; - parm["expireMinutes"] = expireMinutes; - final result = await http.post( - "318de84592dca60c287ba2f54d60756a3d2cb7989afc93dcdbec1a68c53cac37", - data: parm, - fromJson: (json) => SCRoomRedPacketListRes.fromJson(json), - ); - return result; - } - - ///gift/activity/list - @override - Future> giftActivityList() async { - final result = await http.get>( - "60296415428765e04263c733bfc8428c397bb180e31856d94bf25d85eb342787", - fromJson: - (json) => - (json as List).map((e) => SocialChatGiftRes.fromJson(e)).toList(), - ); - return result; - } - - ///room-task/list - @override - Future roomTaskList(int taskCategory) async { - Map parm = {}; - parm["taskCategory"] = taskCategory; - final result = await http.get( - "30743db31a4b5236451c71864f914359", - queryParams: parm, - fromJson: (json) => SCRoomTaskListRes.fromJson(json), - ); - return result; - } - - ///room-task/claim - @override - Future roomTaskClaim(String taskCode) async { - Map parm = {}; - parm["taskCode"] = taskCode; - final result = await http.post( - "439c60bae741f83884ada20b837a00f9", - data: parm, - fromJson: (json) => Tiers.fromJson(json), - ); - return result; - } - - ///room-task/claimable-count - @override - Future roomTaskClaimableCount() async { - final result = await http.get( - "dba51232871855314973867d38c76191ae96d33f4d1b3265974cd070f03c3830", - fromJson: (json) => SCRoomTaskClaimableCountRes.fromJson(json), - ); - return result; - } -} +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'; +import 'package:yumi/shared/business_logic/models/res/sc_is_follow_room_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_mic_go_up_res.dart'; +import 'package:yumi/shared/business_logic/models/res/mic_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_contribute_level_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_join_black_list_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_detail_res.dart'; +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'; +import 'package:yumi/shared/business_logic/models/res/user_count_guard_res.dart'; +import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; +import 'package:provider/provider.dart'; + +import 'package:yumi/shared/business_logic/models/res/sc_banner_leaderboard_res.dart'; +import 'package:yumi/shared/business_logic/models/res/gift_backpack_res.dart'; +import 'package:yumi/shared/business_logic/models/res/gift_by_group_res.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/business_logic/models/res/my_room_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_emoji_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_gift_rank_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_member_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_config_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_red_packet_sent_record_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_user_card_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_violation_handle_res.dart'; +import 'package:yumi/shared/business_logic/repositories/room_repository.dart'; +import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/shared/data_sources/sources/remote/net/api.dart'; +import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart'; +import 'package:yumi/shared/tools/sc_room_profile_cache.dart'; + +class SCChatRoomRepository implements SocialChatRoomRepository { + static SCChatRoomRepository? _instance; + + SCChatRoomRepository._internal(); + + factory SCChatRoomRepository() { + return _instance ??= SCChatRoomRepository._internal(); + } + + void _giftRepoLog(String message) {} + + Future _traceRoomRedPacketApi( + String method, + String path, + Future Function() request, { + dynamic data, + dynamic query, + }) async { + try { + final result = await request(); + return result; + } catch (error, stackTrace) { + _logRoomRedPacketApiError(method, path, error, stackTrace); + rethrow; + } + } + + void _logRoomRedPacketApiError( + String method, + String path, + Object error, + StackTrace stackTrace, + ) {} + + String _debugJson(dynamic value) { + if (value == null) { + return '-'; + } + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } + } + + String _debugResult(dynamic value) { + if (value == null) { + return 'null'; + } + if (value is List) { + return 'List(length=${value.length})'; + } + try { + final dynamic json = value.toJson(); + return '${value.runtimeType} ${_debugJson(json)}'; + } catch (_) { + return value.runtimeType.toString(); + } + } + + void _rocketApiDebug( + String label, { + dynamic query, + dynamic data, + dynamic raw, + dynamic result, + }) { + if (!kDebugMode) { + return; + } + final parts = [ + '[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; + } + + bool _isBrokenLocalMediaUrl(String? url) { + return (url ?? "").contains("/external/oss/local/"); + } + + bool _shouldHydrateRoomProfile(String? roomId, String? roomCover) { + return (roomId ?? "").trim().isNotEmpty && + ((roomCover ?? "").trim().isEmpty || _isBrokenLocalMediaUrl(roomCover)); + } + + Future _hydrateRoomRes(SocialChatRoomRes room) async { + final cachedRoom = SCRoomProfileCache.applyToSocialChatRoomRes(room); + if (!_shouldHydrateRoomProfile(cachedRoom.id, cachedRoom.roomCover)) { + return cachedRoom; + } + final specificRoom = await specific(cachedRoom.id ?? ""); + return SCRoomProfileCache.applyToSocialChatRoomRes( + cachedRoom.copyWith( + roomCover: SCRoomProfileCache.preferNonEmpty( + specificRoom.roomCover, + cachedRoom.roomCover, + ), + roomBackground: SCRoomProfileCache.preferNonEmpty( + specificRoom.roomBackground, + cachedRoom.roomBackground, + ), + roomName: SCRoomProfileCache.preferNonEmpty( + specificRoom.roomName, + cachedRoom.roomName, + ), + roomDesc: SCRoomProfileCache.preferNonEmpty( + specificRoom.roomDesc, + cachedRoom.roomDesc, + ), + ), + ); + } + + ///room/live-voice/discovery + @override + Future> discovery({bool? allRegion}) async { + Map queryParams = {}; + if (allRegion != null) { + queryParams["allRegion"] = allRegion; + } + final result = await http.get( + "363603111e51beac2d183014dd29b81ced652239ec13178acd7001096cab7429", + queryParams: queryParams, + fromJson: + (json) => + (json as List).map((e) => SocialChatRoomRes.fromJson(e)).toList(), + ); + return Future.wait(result.map(_hydrateRoomRes)); + } + + ///live/mic/list + @override + Future> micList( + String roomId, { + bool silentErrorToast = false, + }) async { + Map queryParams = {}; + queryParams["roomId"] = roomId; + final result = await http.get( + "b1d6a1284f5ddeaa1cbb1430f2991e8a", + queryParams: queryParams, + extra: + silentErrorToast + ? const {BaseNetworkClient.silentErrorToastKey: true} + : null, + fromJson: + (json) => (json as List).map((e) => MicRes.fromJson(e)).toList(), + ); + return result; + } + + ///room/profile/specific + @override + Future specific(String roomId) async { + Map queryParams = {}; + queryParams["roomId"] = roomId; + final result = await http.get( + "08c7cb961a83d24c9e0d4b11d7af3f0a8f2fe5ccf18a5ca6a0caf80ecefaeb02", + queryParams: queryParams, + fromJson: (json) => MyRoomRes.fromJson(json), + ); + await SCRoomProfileCache.saveRoomProfile( + roomId: result.id ?? roomId, + roomCover: result.roomCover, + roomBackground: result.roomBackground, + roomName: result.roomName, + roomDesc: result.roomDesc, + ); + return SCRoomProfileCache.applyToMyRoomRes(result); + } + + ///room/relation/status + @override + Future isFollowRoom(String roomId) async { + Map queryParams = {}; + queryParams["roomId"] = roomId; + final result = await http.get( + "1c9e38f372be8ae5afde3a37710c24815dee9b62b097054c3e40e333e7747681", + queryParams: queryParams, + fromJson: (json) => SCIsFollowRoomRes.fromJson(json), + ); + return result; + } + + ///room/contribution/level + @override + Future contributeLevel(String roomId) async { + Map queryParams = {}; + queryParams["roomId"] = roomId; + final result = await http.get( + "730cfe8ab28a5bb6b75adb2d29320bd85285ddc5a507f997b1c6a23980d22a70", + queryParams: queryParams, + fromJson: (json) => SCRoomContributeLevelRes.fromJson(json), + ); + return result; + } + + ///activity/room-contribution-activity + @override + Future roomContributionActivity( + String roomId, + ) async { + Map queryParams = {}; + queryParams["roomId"] = roomId; + final result = await http.get( + "fe8a114454bb0a64c51b5a2840b89b9caad8e1b76b3e1c570f21d856505cee7d1621a5cc87df186c3e223af28d3b4455", + queryParams: queryParams, + fromJson: (json) => SCRoomContributeLevelRes.fromJson(json), + ); + return result; + } + + ///live/mic/go-up + @override + Future micGoUp( + String roomId, + num mickIndex, { + String? eventType, + String? inviterId, + }) async { + Map params = {}; + params["roomId"] = roomId; + params["mickIndex"] = mickIndex; + if (eventType != null) { + params["eventType"] = eventType; + } + if (inviterId != null) { + params["inviterId"] = inviterId; + } + final result = await http.post( + "6fb96d680637b8e931b962f6e05260b0", + data: params, + fromJson: (json) => SCMicGoUpRes.fromJson(json), + ); + return result; + } + + ///live/mic/go-down + @override + Future micGoDown(String roomId, num mickIndex) async { + final result = await http.post( + "11335ecd55d99c9100f5cd070e5b73f1574aff257ce7e668d08f4caccd1c6232", + data: {"roomId": roomId, "mickIndex": mickIndex}, + allowNullBody: true, + fromJson: (json) => null, + ); + return result; + } + + ///live/mic/lock + @override + Future micLock(String roomId, num mickIndex, bool lock) async { + final result = await http.post( + "70ffb9681e0103463b1faf883935e55d", + data: {"roomId": roomId, "mickIndex": mickIndex, "lock": lock}, + allowNullBody: true, + fromJson: (json) => null, + ); + return result; + } + + ///live/mic/mute + @override + Future micMute(String roomId, num mickIndex, bool mute) async { + final result = await http.post( + "97927b83eb6b9fccda567f3bf12cbf49", + data: {"roomId": roomId, "mickIndex": mickIndex, "mute": mute}, + fromJson: (json) => json as bool, + ); + return result; + } + + ///live/user/list + @override + Future> roomOnlineUsers( + String roomId, { + bool silentErrorToast = false, + }) async { + final result = await http.get( + "a01ade8fbb604c0904557a9483fcc4d9", + queryParams: {"roomId": roomId}, + extra: + silentErrorToast + ? const {BaseNetworkClient.silentErrorToastKey: true} + : null, + fromJson: + (json) => + (json as List) + .map((e) => socialChatUserProfileFromJsonWithVipHint(e)!) + .toList(), + ); + return result; + } + + ///user/user-profile/card + @override + Future roomUserCard(String roomId, String userId) async { + final result = await http.get( + "2fa1d4f56bb726558904ce2a50b83f967732a24a30dccae0c57cf03f27319ea4", + queryParams: {"roomId": roomId, "userId": userId}, + fromJson: (json) => RoomUserCardRes.fromJson(json), + ); + return result; + } + + ///gift/list + @override + Future> giftList() async { + final result = await http.get>( + "3a613b7450f8fd9082b988bd21df454d", + fromJson: + (json) => + (json as List).map((e) => SocialChatGiftRes.fromJson(e)).toList(), + ); + return result; + } + + ///gift/group-tab + @override + Future> giftListByGroup() async { + final result = await http.get>( + "bfbb6d98524f1e8aecf695e32ed24c2f", + fromJson: + (json) => + (json as List).map((e) => GiftByGroupRes.fromJson(e)).toList(), + ); + return result; + } + + ///gift/batch + @override + Future giveGift( + List acceptUserIds, + String giftId, + num quantity, + bool checkCombo, { + String? roomId, + SCGiveAwayGiftRoomAcceptsCmd? accepts, + String? dynamicContentId, + }) async { + Map params = {}; + if (roomId != null) { + params["roomId"] = roomId; + } + params["giftId"] = giftId; + params["acceptUserIds"] = acceptUserIds; + params["quantity"] = quantity; + params["checkCombo"] = checkCombo; + if (accepts != null) { + params["accepts"] = accepts.toJson(); + } + if (dynamicContentId != null) { + params["dynamicContentId"] = dynamicContentId; + } + + final result = await http.post( + "daa4f379ff3e429e55c318b6af7291e8", + data: params, + extra: const {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => json as double, + ); + return result; + } + + ///live/mic/kill + @override + Future micKill(String roomId, num mickIndex) async { + final result = await http.post( + "e50ed2f3406bc9421f3271f8be367d9b", + data: {"roomId": roomId, "mickIndex": mickIndex}, + fromJson: (json) => json as bool, + ); + return result; + } + + ///sys/reported + @override + Future reported( + String reportUserId, + String reportedUserId, + num reportType, { + String? reportedContent, + num? relatedId, + String? imageUrls, + String? videoUrls, + }) async { + Map params = {}; + params["reportUserId"] = reportUserId; + params["reportedUserId"] = reportedUserId; + params["reportType"] = reportType; + if (reportedContent != null && reportedContent.isNotEmpty) { + params["reportedContent"] = reportedContent; + } + if (relatedId != null) { + params["relatedId"] = relatedId; + } + if (imageUrls != null && imageUrls.isNotEmpty) { + params["imageUrls"] = imageUrls; + } + if (videoUrls != null && videoUrls.isNotEmpty) { + params["videoUrls"] = videoUrls; + } + final result = await http.post( + "552d752693c91a4a3dd9723634dff21f", + data: params, + fromJson: (json) => json as bool, + ); + return result; + } + + ///room/live-voice/search + @override + Future> searchRoom(String roomAccount) async { + final result = await http.get>( + "363603111e51beac2d183014dd29b81cce9dda0f9cd8c267f6e4fd3f05bda090", + queryParams: {"roomAccount": roomAccount}, + fromJson: + (json) => + (json as List).map((e) => SocialChatRoomRes.fromJson(e)).toList(), + ); + return Future.wait(result.map(_hydrateRoomRes)); + } + + ///room/blacklist/join + @override + Future joinBlacklist( + String roomId, + String userId, + num outTime, + ) async { + final result = await http.post( + "fe07c4b4a0ab1a38a5674c77444296f1a31f65259a63574dbbfbfe543f1b99a6", + data: {"roomId": roomId, "userId": userId, "outTime": outTime}, + fromJson: (json) => SCRoomJoinBlackListRes.fromJson(json), + ); + return result; + } + + ///room/blacklist/remove + @override + Future removeBlacklist(String roomId, String userId) async { + final result = await http.post( + "68900c787a89c1092b21ee1a610ef323380d4f62784dacdb4e3d4e1f60770a39", + data: {"roomId": roomId, "userId": userId}, + allowNullBody: true, + fromJson: (json) => null, + ); + return result; + } + + ///user/count/guard + @override + Future> userCountGuard(String userId) async { + final result = await http.get>( + "cef5832baa42d4adb30f4b10bef0887e574aff257ce7e668d08f4caccd1c6232", + queryParams: {"userId": userId}, + fromJson: + (json) => + (json as List).map((e) => UserCountGuardRes.fromJson(e)).toList(), + ); + return result; + } + + ///material/emoji/all + @override + Future> emojiAll() async { + final result = await http.get>( + "1d2546ea50639e65f04d029dd0a9655eda4aeb3a4bd182b663d60d87cb3f2938", + fromJson: + (json) => + (json as List).map((e) => SCRoomEmojiRes.fromJson(e)).toList(), + ); + return result; + } + + ///room/member/change-role + @override + Future changeRoomRole( + String roomId, + String userType, + String changeUserId, + String operatorUserId, + String reason, + ) async { + final result = await http.put( + "39b2c4ffda0682c126435fc37480a0961be75ad669bd0bf28d4e875a45934dfe", + data: { + "roomId": roomId, + "userType": userType, + "changeUserId": changeUserId, + "operatorUserId": operatorUserId, + "reason": reason, + }, + fromJson: (json) => json as bool, + ); + return result; + } + + ///room/setting/locked + @override + Future roomLocked(String roomId, String password) async { + final result = await http.put( + "e9531da4dd5c83bd5386366519ac6d0b727c997eb7a1adb8323f850b6c541f3b", + data: {"roomId": roomId, "password": password}, + fromJson: (json) => json as bool, + ); + return result; + } + + ///room/setting/unlocked + @override + Future roomUnlocked(String roomId) async { + final result = await http.post( + "d8e78b8fb4cf9f0d41716d9978235334d9382f6a419ce06c2dab0b362c318a5d", + data: {"roomId": roomId}, + fromJson: (json) => json as bool, + ); + return result; + } + + ///room/setting + @override + Future updateRoomSetting( + String roomId, + String roomAcount, + BuildContext context, { + bool? touristMike, + String? takeMicRole, + bool? touristMsg, + String? joinGolds, + num? mikeSize, + bool? allowMusic, + bool? adminLockSeat, + bool? showHeartbeat, + bool? openKtvMode, + String? roomSpecialMikeType, + }) async { + final rtmProvider = Provider.of(context, listen: false); + Map params = {}; + params["roomId"] = roomId; + if (touristMike != null) { + params["touristMike"] = touristMike; + } + if (takeMicRole != null) { + params["takeMicRole"] = takeMicRole; + } + if (touristMsg != null) { + params["touristMsg"] = touristMsg; + } + if (joinGolds != null) { + params["joinGolds"] = joinGolds; + } + if (mikeSize != null) { + params["mikeSize"] = mikeSize; + } + if (allowMusic != null) { + params["allowMusic"] = allowMusic; + } + if (adminLockSeat != null) { + params["adminLockSeat"] = adminLockSeat; + } + if (showHeartbeat != null) { + params["showHeartbeat"] = showHeartbeat; + } + if (openKtvMode != null) { + params["openKtvMode"] = openKtvMode; + } + if (roomSpecialMikeType != null) { + params["roomSpecialMikeType"] = roomSpecialMikeType; + } + final result = await http.put( + "cd447427897a27ec5a710ae5861907c0", + data: params, + fromJson: (json) => json as bool, + ); + + rtmProvider.dispatchMessage( + Msg( + groupId: roomAcount, + msg: roomId, + type: SCRoomMsgType.roomSettingUpdate, + ), + addLocal: false, + ); + return result; + } + + ///room/member + @override + Future> roomMember( + String roomId, { + String? lastId, + }) async { + Map parm = {}; + parm["roomId"] = roomId; + if (lastId != null) { + parm["lastId"] = lastId; + } + final result = await http.get>( + "76393fe91160b664a81fdc0545a586ef", + queryParams: parm, + fromJson: + (json) => + (json as List) + .map((e) => SocialChatRoomMemberRes.fromJson(e)) + .toList(), + ); + return result; + } + + ///room/contribution/rank + @override + Future> roomContributionRank( + String roomId, + String dataType, { + num size = 10, + }) async { + Map parm = {}; + parm["roomId"] = roomId; + parm["dataType"] = dataType; + parm["size"] = size; + final result = await http.get>( + "730cfe8ab28a5bb6b75adb2d29320bd80ed2ff7efd67897d5b80fd620a1b0676", + queryParams: parm, + fromJson: + (json) => + (json as List).map((e) => RoomGiftRankRes.fromJson(e)).toList(), + ); + return result; + } + + ///room/setting/kick-off-microphone + @override + Future kickOffMicrophone(String roomId, String kickUserId) async { + final result = await http.post( + "a4465894e21be08d833ef701566ffe1b6a835338046b35fb94271445c996f6e3574aff257ce7e668d08f4caccd1c6232", + data: {"roomId": roomId, "kickUserId": kickUserId}, + fromJson: (json) => json as bool, + ); + return result; + } + + ///gift/give/lucky-gift + @override + Future giveLuckyGift( + List acceptUserIds, + String giftId, + num quantity, + bool checkCombo, { + String? roomId, + List? accepts, + Object? gameId, + String? dynamicContentId, + String? songId, + }) async { + Map params = {}; + if (roomId != null) { + params["roomId"] = roomId; + } + params["giftId"] = giftId; + params["acceptUserIds"] = acceptUserIds; + params["quantity"] = quantity; + params["checkCombo"] = checkCombo; + params["gameId"] = gameId; + params["accepts"] = + (accepts ?? const []) + .map((item) => item.toJson()) + .toList(); + params["dynamicContentId"] = dynamicContentId; + params["songId"] = songId; + + _giftRepoLog( + 'request giveLuckyGift endpoint=/gift/give/lucky-gift ' + 'giftId=$giftId ' + 'roomId=$roomId ' + 'quantity=$quantity ' + 'checkCombo=$checkCombo ' + 'gameId=$gameId ' + 'dynamicContentId=$dynamicContentId ' + 'songId=$songId ' + 'acceptUserIds=${acceptUserIds.join(",")} ' + 'payload=$params', + ); + final result = await http.post( + "6ddf7ebecfa97adf23c6303877c7763ce591ff710a7abc4fe1e335d16efee21b", + data: params, + extra: const {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => json as double, + ); + _giftRepoLog( + 'response giveLuckyGift endpoint=/gift/give/lucky-gift ' + 'giftId=$giftId ' + 'roomId=$roomId ' + 'balance=$result', + ); + return result; + } + + ///activity/leaderboard/appLeaderboard + @override + Future appLeaderboard() async { + final result = await http.get( + "04f600f838ff20e7f794d3b555103541adda7fb851a14e1c733d58bc5cd3a23b343860371cb9a34e6cdb9005c91c78a9", + fromJson: (json) => SCBannerLeaderboardRes.fromJson(json), + ); + return result; + } + + ///gift/backpack + @override + Future> giftBackpack() async { + final result = await http.get>( + "48ab0afa0e4305070eaa82edc3fc3996", + fromJson: + (json) => + (json as List) + .map((e) => SocialChatGiftBackpackRes.fromJson(e)) + .toList(), + ); + return result; + } + + ///go/app/voice-room/rocket/status + @override + Future rocketStatus(String roomId) async { + Map params = {}; + params["roomId"] = roomId; + const path = "/go/app/voice-room/rocket/status"; + _rocketApiDebug('request GET $path', query: params); + final result = await http.get( + path, + queryParams: params, + 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 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( + 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 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( + 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 ackRoomRocketRewardPopups(List 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( + 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 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( + 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 roomRocketRule() async { + const path = "/go/app/voice-room/rocket/rule"; + _rocketApiDebug('request GET $path'); + final result = await http.get( + path, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) { + _rocketApiDebug('GET $path', raw: json); + return json; + }, + ); + _rocketApiDebug('parsed $path', result: result); + return result; + } + + ///api/rocket/config/enabled + @override + Future> rocketConfigEnabled() async { + final result = await http.get>( + "09620f7f773a75af633e034b80393e0ee8adb46dafdd3600f74d3c85e2c07e4f", + fromJson: + (json) => + (json as List) + .map((e) => SCRoomRocketConfigRes.fromJson(e)) + .toList(), + ); + return result; + } + + ///api/rocket/claim + @override + Future rocketClaim(String roomId) async { + final result = await http.post( + "4c083b13f2be2a26641018619bdc0347574aff257ce7e668d08f4caccd1c6232", + data: {"roomId": roomId}, + fromJson: (json) => json as bool, + ); + return result; + } + + ///room-violation/handle + @override + Future roomViolationHandle( + String roomId, + int violationType, + int operationType, + String description, { + List? imageUrls, + }) async { + Map parm = {}; + parm["roomId"] = roomId; + parm["violationType"] = violationType; + parm["operationType"] = operationType; + parm["description"] = description; + if (imageUrls != null && imageUrls.isNotEmpty) { + parm["screenshotUrls"] = imageUrls; + } + final result = await http.post( + "fc47c4e6e3ba228391cd35bf99f301206f894acdcdbac7ccb7e88e9d69ee3eff", + data: parm, + fromJson: (json) => SCViolationHandleRes.fromJson(json), + ); + return result; + } + + ///room/red-packet/list + @override + Future> roomRedPacketList( + String roomId, + int current, { + int size = 20, + }) async { + Map parm = {}; + parm["roomId"] = roomId; + parm["current"] = current; + parm["size"] = size; + const path = + "318de84592dca60c287ba2f54d60756abc4a9e8c4704c0f73c085e7ab50e7fdb"; + return _traceRoomRedPacketApi( + 'GET', + path, + () => http.get>( + path, + queryParams: parm, + fromJson: + (json) => + (json as List) + .map((e) => SCRoomRedPacketListRes.fromJson(e)) + .toList(), + ), + query: parm, + ); + } + + ///room/red-packet/create + @override + Future roomSendRedPacket( + String roomId, + String totalAmount, + String totalCount, + String expireMinutes, { + String packetMode = 'DELAYED', + }) async { + Map parm = {}; + parm["requestId"] = + "room-red-packet-${DateTime.now().microsecondsSinceEpoch}"; + parm["roomId"] = _jsonInt64(roomId); + parm["packetMode"] = packetMode; + parm["totalAmount"] = _jsonInt64(totalAmount); + parm["totalCount"] = _jsonInt64(totalCount); + const path = "/go/app/voice-room/red-packet/send"; + return _traceRoomRedPacketApi( + 'POST', + path, + () => http.post( + path, + data: parm, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketListRes.fromJson(json), + ), + data: parm, + ); + } + + ///room/red-packet/grab + @override + Future roomRedPacketGrab(String packetId) async { + Map parm = {}; + parm["packetNo"] = packetId; + const path = "/go/app/voice-room/red-packet/claim"; + return _traceRoomRedPacketApi( + 'POST', + path, + () => http.post( + path, + data: parm, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketGrabRes.fromJson(json), + ), + data: parm, + ); + } + + ///room/red-packet/detail + @override + Future roomRedPacketDetail(String packetId) async { + Map parm = {}; + parm["packetNo"] = packetId; + const path = "/go/app/voice-room/red-packet/detail"; + return _traceRoomRedPacketApi( + 'GET', + path, + () => http.get( + path, + queryParams: parm, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketDetailRes.fromJson(json), + ), + query: parm, + ); + } + + @override + Future roomRedPacketConfig() async { + const path = "/go/app/voice-room/red-packet/config"; + return _traceRoomRedPacketApi( + 'GET', + path, + () => http.get( + path, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketConfigRes.fromJson(json), + ), + ); + } + + @override + Future roomRedPacketImGroup() async { + const path = "/go/app/voice-room/red-packet/im-group"; + return _traceRoomRedPacketApi( + 'GET', + path, + () => http.get( + path, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketImGroupRes.fromJson(json), + ), + ); + } + + @override + Future roomRedPacketPresenceHeartbeat( + String roomId, + ) async { + const path = "/go/app/voice-room/red-packet/presence/heartbeat"; + final data = {"roomId": _jsonInt64(roomId)}; + return _traceRoomRedPacketApi( + 'POST', + path, + () => http.post( + path, + data: data, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json), + ), + data: data, + ); + } + + @override + Future roomRedPacketPresenceLeave( + String roomId, + ) async { + const path = "/go/app/voice-room/red-packet/presence/leave"; + final data = {"roomId": _jsonInt64(roomId)}; + return _traceRoomRedPacketApi( + 'POST', + path, + () => http.post( + path, + data: data, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketPresenceRes.fromJson(json), + ), + data: data, + ); + } + + @override + Future> roomRedPacketSentRecords({ + int cursor = 1, + int limit = 20, + }) async { + const path = "/go/app/voice-room/red-packet/sent-records"; + final query = {"cursor": cursor, "limit": limit}; + return _traceRoomRedPacketApi( + 'GET', + path, + () => http.get>( + path, + queryParams: query, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: + (json) => + _redPacketRecordListBody( + json, + ).map((e) => SCRoomRedPacketSentRecordRes.fromJson(e)).toList(), + ), + query: query, + ); + } + + @override + Future roomRedPacketClaimRecords( + String packetNo, + ) async { + const path = "/go/app/voice-room/red-packet/claim-records"; + final query = {"packetNo": packetNo}; + return _traceRoomRedPacketApi( + 'GET', + path, + () => http.get( + path, + queryParams: query, + extra: {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCRoomRedPacketClaimRecordsRes.fromJson(json), + ), + query: query, + ); + } + + List _redPacketRecordListBody(dynamic json) { + if (json is List) { + return json; + } + if (json is Map) { + for (final key in const ['records', 'list', 'items']) { + final value = json[key]; + if (value is List) { + return value; + } + } + } + return const []; + } + + ///activity/room-contribution-activity/room-reward/info + @override + Future roomRewardInfo(String roomId) async { + Map parm = {}; + parm["roomId"] = roomId; + final result = await http.get( + "fe8a114454bb0a64c51b5a2840b89b9caad8e1b76b3e1c570f21d856505cee7ddf616bd4bdc133af05422ff3f7becc575ad9f024e25ab8275b5792a94159a781", + queryParams: parm, + fromJson: (json) => SCRoomRewardInfoRes.fromJson(json), + ); + return result; + } + + ///activity/room-contribution-activity/room-reward/receive + @override + Future roomRewardReceive(String roomId) async { + Map parm = {}; + parm["roomId"] = roomId; + final result = await http.post( + "1224764475bbd027233e79b81fbdbbf8b25565bc1234221b5e7f9651e0d97576fbcc314a4dd141539dc44cd84f2f13e4c35b35e57872c47078faccd94224944e", + data: parm, + fromJson: (json) => json as bool, + ); + return result; + } + + ///room/red-packet/create-platform + @override + Future sendPlatformRedPack( + String roomId, + String totalAmount, + String totalCount, + String expireMinutes, + ) async { + Map parm = {}; + parm["roomId"] = roomId; + parm["totalAmount"] = totalAmount; + parm["totalCount"] = totalCount; + parm["expireMinutes"] = expireMinutes; + final result = await http.post( + "318de84592dca60c287ba2f54d60756a3d2cb7989afc93dcdbec1a68c53cac37", + data: parm, + fromJson: (json) => SCRoomRedPacketListRes.fromJson(json), + ); + return result; + } + + ///gift/activity/list + @override + Future> giftActivityList() async { + final result = await http.get>( + "60296415428765e04263c733bfc8428c397bb180e31856d94bf25d85eb342787", + fromJson: + (json) => + (json as List).map((e) => SocialChatGiftRes.fromJson(e)).toList(), + ); + return result; + } + + ///room-task/list + @override + Future roomTaskList(int taskCategory) async { + Map parm = {}; + parm["taskCategory"] = taskCategory; + final result = await http.get( + "30743db31a4b5236451c71864f914359", + queryParams: parm, + fromJson: (json) => SCRoomTaskListRes.fromJson(json), + ); + return result; + } + + ///room-task/claim + @override + Future roomTaskClaim(String taskCode) async { + Map parm = {}; + parm["taskCode"] = taskCode; + final result = await http.post( + "439c60bae741f83884ada20b837a00f9", + data: parm, + fromJson: (json) => Tiers.fromJson(json), + ); + return result; + } + + ///room-task/claimable-count + @override + Future roomTaskClaimableCount() async { + final result = await http.get( + "dba51232871855314973867d38c76191ae96d33f4d1b3265974cd070f03c3830", + fromJson: (json) => SCRoomTaskClaimableCountRes.fromJson(json), + ); + return result; + } +} diff --git a/lib/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart b/lib/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart new file mode 100644 index 0000000..cefea85 --- /dev/null +++ b/lib/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart @@ -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 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 createState() => _RoomCpInviteDialogState(); +} + +class _RoomCpInviteDialogState extends State { + 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, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart b/lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart new file mode 100644 index 0000000..8b2ec32 --- /dev/null +++ b/lib/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart @@ -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 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart index 9a5624c..78b6562 100644 --- a/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart +++ b/lib/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart @@ -1,288 +1,288 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_debouncer/flutter_debouncer.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:yumi/shared/tools/sc_room_utils.dart'; -import 'package:yumi/main.dart'; -import 'package:marquee/marquee.dart'; - -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; - -class FloatingRoomRocketScreenWidget extends StatefulWidget { - final SCFloatingMessage message; - final VoidCallback onAnimationCompleted; // 动画完成回调 - - const FloatingRoomRocketScreenWidget({ - Key? key, - required this.message, - required this.onAnimationCompleted, - }) : super(key: key); - - @override - _FloatingRoomRocketScreenWidgetState createState() => - _FloatingRoomRocketScreenWidgetState(); -} - -class _FloatingRoomRocketScreenWidgetState - extends State - with TickerProviderStateMixin { - Debouncer debouncer = Debouncer(); - late AnimationController _controller; - late Animation _firstAnimation; // 第一段:从右到中 - late Animation _secondAnimation; // 第二段:从中到左 - late AnimationController _swipeController; // 新增:滑动动画控制器 - late Animation _swipeAnimation; // 新增:滑动动画 - - bool _firstPhaseCompleted = false; - bool _isSwipeAnimating = false; // 标记是否正在执行滑动动画 - - @override - void initState() { - super.initState(); - // 主动画控制器 - _controller = AnimationController( - duration: const Duration(milliseconds: 1500), // 每段动画1.5秒 - vsync: this, - ); - - // 滑动动画控制器 - _swipeController = AnimationController( - duration: const Duration(milliseconds: 550), // 滑动动画500ms - vsync: this, - ); - - // 监听滑动动画完成 - _swipeController.addStatusListener((status) { - if (status == AnimationStatus.completed) { - widget.onAnimationCompleted(); - } - }); - - // 第一段动画:从右侧飘到中间 - _firstAnimation = Tween( - begin: const Offset(1.5, 0.0), // 从屏幕右侧外开始 - end: Offset.zero, // 到屏幕中心 - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); - - // 第二段动画:从中间飘到左侧 - _secondAnimation = Tween( - begin: Offset.zero, // 从屏幕中心开始 - end: const Offset(-1.5, 0.0), // 到屏幕左侧外 - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); - - // 滑动动画:从当前位置滑动到左侧屏幕外 - _swipeAnimation = Tween( - begin: Offset.zero, // 从当前位置开始 - end: const Offset(-1.5, 0.0), // 到屏幕左侧外 - ).animate(CurvedAnimation( - parent: _swipeController, - curve: Curves.easeIn, - )); - - // 延迟启动动画 - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _startAnimationSequence(); - } - }); - } - void _startAnimationSequence() async { - // 第一阶段:从右到中 - _controller.reset(); - await _controller.forward(); - - // 停顿2秒(你代码中是3秒,这里保持原样) - await Future.delayed(const Duration(seconds: 3)); - - if (!mounted) return; - - // 第二阶段:从中到左 - setState(() { - _firstPhaseCompleted = true; - }); - _controller.reset(); - await _controller.forward(); - - if (mounted && !_isSwipeAnimating) { - widget.onAnimationCompleted(); - } - } - - - @override - void dispose() { - _controller.dispose(); - _swipeController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // 如果正在执行滑动动画,使用滑动动画 - if (_isSwipeAnimating) { - return SlideTransition( - position: _swipeAnimation, - child: _buildRocketAnimation(), - ); - } - - // 否则使用原来的动画 - return SlideTransition( - position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation, - child: _buildRocketAnimation(), - ); - } - - Widget _buildRocketAnimation() { - // 使用Positioned.fill确保飘屏覆盖全屏宽度,并定位在特定高度(如顶部100像素) - return GestureDetector( - onTap: () { - debouncer.debounce( - duration: Duration(milliseconds: 350), - onDebounce: () { - if (widget.message.roomId != null && - widget.message.roomId!.isNotEmpty) { - SCRoomUtils.goRoom( - widget.message.roomId!, - navigatorKey.currentState!.context, - fromFloting: true - ); - } - }, - ); - }, - onHorizontalDragEnd: (details) { - // 获取当前的文本方向 - final textDirection = Directionality.of(context); - - // 定义一个根据方向转换速度符号的辅助函数 - double effectiveVelocity(double velocity) { - // 在RTL模式下,反转速度的正负号 - return textDirection == TextDirection.rtl - ? -velocity - : velocity; - } - - double velocity = effectiveVelocity(details.primaryVelocity ?? 0); - if (velocity < 0) { - ///向左滑动,把当前视图向左平移出去 - _handleSwipeLeft(); - } - }, - child: Align( - alignment: Alignment.topCenter, - child: Directionality( - textDirection: TextDirection.ltr, - child: Container( - alignment: Alignment.topCenter, - height: 128.w, - width: 290.w, - margin: EdgeInsets.only(top: 10.w), - padding: EdgeInsetsDirectional.only(start: 3.w, end: 25.w), - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - "sc_images/index/sc_icon_rocket_broad_bg_lv${widget.message.rocketLevel}.webp", - ), - fit: BoxFit.fill, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, // 宽度由内容决定 - children: [ - SizedBox(width: 5.w), - Container( - child: head( - url: widget.message.userAvatarUrl ?? "", - width: 66.w, - ), - ), - SizedBox(width: 4.w), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: 6.w), - Row( - children: [ - SizedBox( - width: 60.w, - height: 22.w, - child: Marquee( - text: "ID:${widget.message.userId} ", - style: TextStyle( - fontSize: 15.sp, - color: Colors.yellow, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w900, - decoration: TextDecoration.none, - ), - scrollAxis: Axis.horizontal, - crossAxisAlignment: - CrossAxisAlignment.start, - blankSpace: 20.0, - velocity: 40.0, - pauseAfterRound: Duration(seconds: 1), - accelerationDuration: Duration(seconds: 1), - accelerationCurve: Curves.easeOut, - decelerationDuration: Duration( - milliseconds: 500, - ), - decelerationCurve: Curves.easeOut, - ), - ), - text( - SCAppLocalizations.of(context)!.launchedARocket, - textColor: Colors.white, - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic, - fontSize: 13.sp, - ), - ], - ), - Transform.translate( - offset: Offset(0, -5), - child: text( - SCAppLocalizations.of(context)!.letGoToWatch, - textColor: Colors.white, - fontWeight: FontWeight.w900, - fontSize: 17.sp, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - // 处理向左滑动 - void _handleSwipeLeft() { - if (_isSwipeAnimating) return; - - setState(() { - _isSwipeAnimating = true; - }); - - // 停止主动画 - _controller.stop(); - - // 启动滑动动画 - _swipeController.reset(); - _swipeController.forward(); - } -} +import 'package:flutter/material.dart'; +import 'package:flutter_debouncer/flutter_debouncer.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; +import 'package:yumi/ui_kit/components/text/sc_text.dart'; +import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/main.dart'; +import 'package:marquee/marquee.dart'; + +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; + +class FloatingRoomRocketScreenWidget extends StatefulWidget { + final SCFloatingMessage message; + final VoidCallback onAnimationCompleted; // 动画完成回调 + + const FloatingRoomRocketScreenWidget({ + Key? key, + required this.message, + required this.onAnimationCompleted, + }) : super(key: key); + + @override + _FloatingRoomRocketScreenWidgetState createState() => + _FloatingRoomRocketScreenWidgetState(); +} + +class _FloatingRoomRocketScreenWidgetState + extends State + with TickerProviderStateMixin { + Debouncer debouncer = Debouncer(); + late AnimationController _controller; + late Animation _firstAnimation; // 第一段:从右到中 + late Animation _secondAnimation; // 第二段:从中到左 + late AnimationController _swipeController; // 新增:滑动动画控制器 + late Animation _swipeAnimation; // 新增:滑动动画 + + bool _firstPhaseCompleted = false; + bool _isSwipeAnimating = false; // 标记是否正在执行滑动动画 + + @override + void initState() { + super.initState(); + // 主动画控制器 + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), // 每段动画1.5秒 + vsync: this, + ); + + // 滑动动画控制器 + _swipeController = AnimationController( + duration: const Duration(milliseconds: 550), // 滑动动画500ms + vsync: this, + ); + + // 监听滑动动画完成 + _swipeController.addStatusListener((status) { + if (status == AnimationStatus.completed) { + widget.onAnimationCompleted(); + } + }); + + // 第一段动画:从右侧飘到中间 + _firstAnimation = Tween( + begin: const Offset(1.5, 0.0), // 从屏幕右侧外开始 + end: Offset.zero, // 到屏幕中心 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); + + // 第二段动画:从中间飘到左侧 + _secondAnimation = Tween( + begin: Offset.zero, // 从屏幕中心开始 + end: const Offset(-1.5, 0.0), // 到屏幕左侧外 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); + + // 滑动动画:从当前位置滑动到左侧屏幕外 + _swipeAnimation = Tween( + begin: Offset.zero, // 从当前位置开始 + end: const Offset(-1.5, 0.0), // 到屏幕左侧外 + ).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn)); + + // 延迟启动动画 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _startAnimationSequence(); + } + }); + } + + void _startAnimationSequence() async { + // 第一阶段:从右到中 + _controller.reset(); + await _controller.forward(); + + await Future.delayed(_holdDuration); + + if (!mounted) return; + + // 第二阶段:从中到左 + setState(() { + _firstPhaseCompleted = true; + }); + _controller.reset(); + await _controller.forward(); + + if (mounted && !_isSwipeAnimating) { + widget.onAnimationCompleted(); + } + } + + Duration get _holdDuration { + final totalSeconds = widget.message.durationSeconds; + final totalMs = totalSeconds == null ? 6000 : (totalSeconds * 1000).round(); + final holdMs = totalMs - 3000; + return Duration(milliseconds: holdMs.clamp(0, 10000).toInt()); + } + + @override + void dispose() { + _controller.dispose(); + _swipeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 如果正在执行滑动动画,使用滑动动画 + if (_isSwipeAnimating) { + return SlideTransition( + position: _swipeAnimation, + child: _buildRocketAnimation(), + ); + } + + // 否则使用原来的动画 + return SlideTransition( + position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation, + child: _buildRocketAnimation(), + ); + } + + Widget _buildRocketAnimation() { + // 使用Positioned.fill确保飘屏覆盖全屏宽度,并定位在特定高度(如顶部100像素) + return GestureDetector( + onTap: () { + debouncer.debounce( + duration: Duration(milliseconds: 350), + onDebounce: () { + if (widget.message.roomId != null && + widget.message.roomId!.isNotEmpty) { + SCRoomUtils.goRoom( + widget.message.roomId!, + navigatorKey.currentState!.context, + fromFloting: true, + ); + } + }, + ); + }, + onHorizontalDragEnd: (details) { + // 获取当前的文本方向 + final textDirection = Directionality.of(context); + + // 定义一个根据方向转换速度符号的辅助函数 + double effectiveVelocity(double velocity) { + // 在RTL模式下,反转速度的正负号 + return textDirection == TextDirection.rtl ? -velocity : velocity; + } + + double velocity = effectiveVelocity(details.primaryVelocity ?? 0); + if (velocity < 0) { + ///向左滑动,把当前视图向左平移出去 + _handleSwipeLeft(); + } + }, + child: Align( + alignment: Alignment.topCenter, + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + alignment: Alignment.topCenter, + height: 128.w, + width: 290.w, + margin: EdgeInsets.only(top: 10.w), + padding: EdgeInsetsDirectional.only(start: 3.w, end: 25.w), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + "sc_images/index/sc_icon_rocket_broad_bg_lv${widget.message.rocketLevel}.webp", + ), + fit: BoxFit.fill, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, // 宽度由内容决定 + children: [ + SizedBox(width: 5.w), + Container( + child: head( + url: widget.message.userAvatarUrl ?? "", + width: 66.w, + ), + ), + SizedBox(width: 4.w), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 6.w), + Row( + children: [ + SizedBox( + width: 60.w, + height: 22.w, + child: Marquee( + text: "ID:${widget.message.userId} ", + style: TextStyle( + fontSize: 15.sp, + color: Colors.yellow, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w900, + decoration: TextDecoration.none, + ), + scrollAxis: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.start, + blankSpace: 20.0, + velocity: 40.0, + pauseAfterRound: Duration(seconds: 1), + accelerationDuration: Duration(seconds: 1), + accelerationCurve: Curves.easeOut, + decelerationDuration: Duration( + milliseconds: 500, + ), + decelerationCurve: Curves.easeOut, + ), + ), + text( + SCAppLocalizations.of(context)!.launchedARocket, + textColor: Colors.white, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + fontSize: 13.sp, + ), + ], + ), + Transform.translate( + offset: Offset(0, -5), + child: text( + SCAppLocalizations.of(context)!.letGoToWatch, + textColor: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 17.sp, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + // 处理向左滑动 + void _handleSwipeLeft() { + if (_isSwipeAnimating) return; + + setState(() { + _isSwipeAnimating = true; + }); + + // 停止主动画 + _controller.stop(); + + // 启动滑动动画 + _swipeController.reset(); + _swipeController.forward(); + } +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart new file mode 100644 index 0000000..107f4b4 --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart @@ -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? 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? 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? crewFromStatus( + SCRoomRocketStatusRes? status, + ) { + final kings = status?.rocketKings ?? const []; + if (kings.isEmpty) { + return const []; + } + return kings + .take(3) + .map( + (item) => RoomRocketCrewMember( + imageUrl: item.userAvatar.isEmpty ? null : item.userAvatar, + ), + ) + .toList(growable: false); + } + + static List rewardItemsFromRecords( + List records, + ) { + return records.map(_rewardItemFromRecord).toList(growable: false); + } + + static List winnersFromRecords( + List records, + ) { + final grouped = >{}; + 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 recordItemsFromRecords( + List records, + ) { + final grouped = >{}; + 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? 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 ''; + } +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart new file mode 100644 index 0000000..f204958 --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart @@ -0,0 +1,1203 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; +import 'package:yumi/ui_kit/components/custom_cached_image.dart'; + +class RoomRocketDialog extends StatelessWidget { + const RoomRocketDialog({ + super.key, + this.status, + this.progressStage, + this.bottomStage, + this.levels, + this.selectedLevel = 0, + this.onLevelSelected, + this.rewards, + this.crew, + this.onClose, + this.onOpenRank, + this.onRewardSelected, + this.onOpenRecord, + this.onOpenNote, + }); + + final SCRoomRocketStatusRes? status; + final RoomRocketProgressStage? progressStage; + final RoomRocketBottomStage? bottomStage; + final List? levels; + final int selectedLevel; + final ValueChanged? onLevelSelected; + final List? rewards; + final List? crew; + final VoidCallback? onClose; + final VoidCallback? onOpenRank; + final ValueChanged? onRewardSelected; + final VoidCallback? onOpenRecord; + final VoidCallback? onOpenNote; + + @override + Widget build(BuildContext context) { + final resolvedLevels = _normalizeLevels(levels); + final resolvedRewards = _normalizeRewards(rewards); + final resolvedCrew = _normalizeCrew(crew, status); + final percent = _normalizePercent(status?.energyPercent); + final resolvedProgressStage = + progressStage ?? RoomRocketProgressStage.fromPercent(percent); + final resolvedBottomStage = + bottomStage ?? + (resolvedCrew.isEmpty + ? RoomRocketBottomStage.empty + : RoomRocketBottomStage.active); + final round = (status?.roundNo ?? status?.level ?? 1).clamp(1, 99).toInt(); + + 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: _RocketCanvas( + scale: scale, + percent: percent, + progressStage: resolvedProgressStage, + bottomStage: resolvedBottomStage, + round: round, + levels: resolvedLevels, + selectedLevel: selectedLevel, + onLevelSelected: onLevelSelected, + rewards: resolvedRewards, + crew: resolvedCrew, + onClose: onClose, + onOpenRank: onOpenRank, + onRewardSelected: onRewardSelected, + onOpenRecord: onOpenRecord, + onOpenNote: onOpenNote, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + static List _normalizeLevels( + List? levels, + ) { + final source = + levels + ?.where((item) => item.label.trim().isNotEmpty) + .toList(growable: false) ?? + const []; + if (source.isNotEmpty) { + return source; + } + return List.generate( + 6, + (index) => RoomRocketLevelItem(label: 'Lv.${index + 1}'), + growable: false, + ); + } + + static List _normalizeRewards( + List? rewards, + ) { + final source = + rewards?.where((item) => item.amount.trim().isNotEmpty).toList() ?? + const []; + if (source.isNotEmpty) { + return source; + } + return const [ + RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'), + RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'), + RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'), + RoomRocketRewardItem(asset: _RocketAssets.rewardThumb, amount: '10000'), + ]; + } + + static List _normalizeCrew( + List? crew, + SCRoomRocketStatusRes? status, + ) { + final hasNoContributors = + status != null && (status.totalContributors ?? 0) <= 0; + if (hasNoContributors) { + return const []; + } + final source = + crew?.where((item) => item.asset != null || item.imageUrl != null) ?? + const []; + if (source.isNotEmpty) { + return source.take(3).toList(); + } + return const []; + } + + static double _normalizePercent(num? value) { + if (value == null) { + return 100; + } + final raw = value.toDouble(); + final percent = raw > 0 && raw <= 1 ? raw * 100 : raw; + return percent.clamp(0, 100).toDouble(); + } +} + +class RoomRocketRewardItem { + const RoomRocketRewardItem({this.asset, this.imageUrl, required this.amount}); + + final String? asset; + final String? imageUrl; + final String amount; +} + +class RoomRocketLevelItem { + const RoomRocketLevelItem({ + required this.label, + this.asset, + this.imageUrl, + this.enabled = true, + }); + + final String label; + final String? asset; + final String? imageUrl; + final bool enabled; +} + +class RoomRocketCrewMember { + const RoomRocketCrewMember({this.asset, this.imageUrl}); + + final String? asset; + final String? imageUrl; +} + +enum RoomRocketProgressStage { + empty, + charging, + full; + + static RoomRocketProgressStage fromPercent(double percent) { + if (percent <= 0) { + return RoomRocketProgressStage.empty; + } + if (percent >= 99.5) { + return RoomRocketProgressStage.full; + } + return RoomRocketProgressStage.charging; + } +} + +enum RoomRocketBottomStage { empty, active } + +class _RocketCanvas extends StatelessWidget { + const _RocketCanvas({ + required this.scale, + required this.percent, + required this.progressStage, + required this.bottomStage, + required this.round, + required this.levels, + required this.selectedLevel, + this.onLevelSelected, + required this.rewards, + required this.crew, + this.onClose, + this.onOpenRank, + this.onRewardSelected, + this.onOpenRecord, + this.onOpenNote, + }); + + final double scale; + final double percent; + final RoomRocketProgressStage progressStage; + final RoomRocketBottomStage bottomStage; + final int round; + final List levels; + final int selectedLevel; + final ValueChanged? onLevelSelected; + final List rewards; + final List crew; + final VoidCallback? onClose; + final VoidCallback? onOpenRank; + final ValueChanged? onRewardSelected; + final VoidCallback? onOpenRecord; + final VoidCallback? onOpenNote; + + double s(double value) => value * scale; + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + _image(_RocketAssets.rocketStage, 15, 361, 344, 97), + _image(_RocketAssets.rocketBody, 88, 111, 200, 432), + _image(_RocketAssets.sidePanel, 309, 202, 66, 207), + _image(_RocketAssets.sidePanel, 0, 202, 66, 207), + _buildLevelRail(), + _buildProgress(), + _buildSideActions(), + _buildRoundTitle(), + _buildResetTime(), + _buildRewardPanel(), + _buildBottomStrip(), + ], + ); + } + + Widget _image( + String asset, + double left, + double top, + double width, + double height, { + double opacity = 1, + BoxFit fit = BoxFit.fill, + }) { + return Positioned( + left: s(left), + top: s(top), + width: s(width), + height: s(height), + child: Opacity( + opacity: opacity, + child: Image.asset(asset, fit: fit, gaplessPlayback: true), + ), + ); + } + + Widget _buildRoundTitle() { + return Positioned( + left: 0, + top: s(120), + width: s(_designWidth), + child: _GradientText( + 'Round $round Today', + fontSize: s(16), + fontWeight: FontWeight.w600, + textAlign: TextAlign.center, + ), + ); + } + + Widget _buildLevelRail() { + return Positioned( + left: s(13), + top: s(212), + width: s(40), + height: s(190), + child: ScrollConfiguration( + behavior: const _NoGlowScrollBehavior(), + child: ListView.separated( + padding: EdgeInsets.zero, + primary: false, + physics: const BouncingScrollPhysics(), + itemCount: levels.length, + separatorBuilder: (_, __) => SizedBox(height: s(4)), + itemBuilder: (context, index) { + final item = levels[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: item.enabled ? () => onLevelSelected?.call(index) : null, + child: SizedBox( + width: s(40), + height: s(52), + child: _LevelBadge( + scale: scale, + item: item, + selected: index == selectedLevel, + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildProgress() { + final asset = switch (progressStage) { + RoomRocketProgressStage.empty => _RocketAssets.progressEmpty, + RoomRocketProgressStage.charging => _RocketAssets.progressPartial, + RoomRocketProgressStage.full => _RocketAssets.progressFull, + }; + return Stack( + children: [ + _image(asset, 320, 227, 44, 134), + Positioned( + left: s(316), + top: s(373), + width: s(56), + child: _GradientText( + '${percent.round()}%', + fontSize: s(16), + fontWeight: FontWeight.w600, + textAlign: TextAlign.center, + colors: const [Color(0xFFA4FFE3), Color(0xFF21C593)], + ), + ), + ], + ); + } + + Widget _buildSideActions() { + return Stack( + children: [ + _image(_RocketAssets.pillBg, 306, 122, 220, 33), + Positioned( + left: s(335), + top: s(127), + width: s(22), + height: s(22), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onOpenNote, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFFA4FFE3).withValues(alpha: 0.95), + width: s(1), + ), + ), + child: Center( + child: Text( + '?', + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white, + fontSize: s(15), + height: 1, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + ), + ), + ), + ), + _image(_RocketAssets.pillBg, 306, 159, 220, 33), + Positioned( + left: s(306), + top: s(159), + width: s(69), + height: s(33), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onOpenRecord, + child: Center( + child: _GradientText( + 'Record', + fontSize: s(14), + fontWeight: FontWeight.w600, + textAlign: TextAlign.center, + ), + ), + ), + ), + ], + ); + } + + Widget _buildResetTime() { + return Stack( + children: [ + _image(_RocketAssets.pillBg, 78, 458, 220, 33), + Positioned( + left: s(109), + top: s(468), + child: _RocketText( + 'Reset Time', + fontSize: s(12), + color: Colors.white, + ), + ), + for (final item in const [ + _TimerToken('23', 193), + _TimerToken('23', 223), + _TimerToken('23', 253), + ]) + Positioned( + left: s(item.left), + top: s(468), + child: _RocketText( + item.value, + fontSize: s(12), + color: Color(0xFF2DFFC1), + ), + ), + for (final left in const [209.0, 239.0]) + Positioned( + left: s(left), + top: s(468), + child: _RocketText( + ':', + fontSize: s(12), + color: const Color(0xFF2DFFC1), + ), + ), + ], + ); + } + + Widget _buildRewardPanel() { + return Stack( + children: [ + _image(_RocketAssets.rewardTop, 8, 499, 360, 63), + _image(_RocketAssets.rewardMiddle, 8, 559, 360, 59), + _image(_RocketAssets.rewardMiddle, 8, 594, 360, 59), + _image(_RocketAssets.rewardBottom, 8, 653, 360, 76), + Positioned( + left: 0, + top: s(519), + width: s(_designWidth), + child: const _GradientText( + 'Reward', + fontSize: 16, + fontWeight: FontWeight.w600, + textAlign: TextAlign.center, + ).scaled(scale), + ), + Positioned( + left: s(28), + top: s(543), + width: s(320), + height: math.max(1, s(1)), + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFB2FFE7).withValues(alpha: 0.16), + ), + ), + ), + _RewardTab( + scale: scale, + left: 31, + top: 552, + label: 'Top1', + active: true, + ), + _RewardTab(scale: scale, left: 147, top: 552, label: 'Set off'), + _RewardTab(scale: scale, left: 252, top: 552, label: 'In the room'), + Positioned( + left: s(28), + top: s(578), + width: s(336), + height: s(135), + child: ClipRect( + child: ScrollConfiguration( + behavior: const _NoGlowScrollBehavior(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + children: [ + for (var index = 0; index < rewards.length; index++) ...[ + SizedBox( + width: s(97), + height: s(135), + child: _RewardCard( + scale: scale, + item: rewards[index], + onTap: () => onRewardSelected?.call(rewards[index]), + ), + ), + if (index != rewards.length - 1) SizedBox(width: s(12)), + ], + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildBottomStrip() { + final activeBottom = bottomStage == RoomRocketBottomStage.active; + final showCrew = activeBottom && crew.isNotEmpty; + return Stack( + children: [ + Positioned( + left: s(0), + top: s(729), + width: s(375), + height: s(83), + child: Transform.rotate( + angle: math.pi, + child: Image.asset( + activeBottom + ? _RocketAssets.bottomStripActive + : _RocketAssets.bottomStripEmpty, + fit: BoxFit.fill, + gaplessPlayback: true, + ), + ), + ), + _image(_RocketAssets.bottomTrophy, 31, 740, 52, 69), + if (!activeBottom) + Positioned( + left: s(108), + top: s(750), + width: s(169), + height: s(36), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: Text( + 'Not up to Rocket rank; no\n“Rocket King” title', + textScaler: TextScaler.noScaling, + maxLines: 2, + softWrap: false, + overflow: TextOverflow.visible, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: s(14), + height: 16 / 14, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ), + ), + if (showCrew) + for (var index = 0; index < crew.length.clamp(0, 3); index++) + Positioned( + left: s(97 + 71.0 * index), + top: s(753), + width: s(50), + height: s(50), + child: _CrewAvatar(scale: scale, member: crew[index]), + ), + Positioned( + left: s(315), + top: s(753), + width: s(52), + height: s(52), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onOpenRank ?? onClose, + child: Center( + child: Image.asset( + _RocketAssets.backArrow, + width: s(32), + height: s(32), + fit: BoxFit.fill, + gaplessPlayback: true, + ), + ), + ), + ), + Positioned( + left: s(0), + top: s(729), + width: s(375), + height: s(83), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onOpenRank ?? onClose, + child: const SizedBox.expand(), + ), + ), + ], + ); + } +} + +class _RewardCard extends StatelessWidget { + const _RewardCard({required this.scale, required this.item, this.onTap}); + + final double scale; + final RoomRocketRewardItem item; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Stack( + children: [ + Positioned.fill(child: CustomPaint(painter: _RewardCardPainter())), + Positioned( + left: 23 * scale, + top: 24 * scale, + width: 50 * scale, + height: 50 * scale, + child: _RocketImage( + asset: item.asset, + imageUrl: item.imageUrl, + fit: BoxFit.cover, + ), + ), + Positioned( + left: 21 * scale, + top: 106 * scale, + width: 14 * scale, + height: 14 * scale, + child: Image.asset(_RocketAssets.coin, fit: BoxFit.contain), + ), + Positioned( + left: 37 * scale, + top: 105 * scale, + width: 46 * scale, + child: _RocketText( + item.amount, + fontSize: 14 * scale, + color: Colors.white, + textAlign: TextAlign.left, + ), + ), + ], + ), + ); + } +} + +class _LevelBadge extends StatelessWidget { + const _LevelBadge({ + required this.scale, + required this.item, + required this.selected, + }); + + final double scale; + final RoomRocketLevelItem item; + final bool selected; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40 * scale, + height: 52 * scale, + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: _LevelBadgePainter( + selected: selected, + enabled: item.enabled, + ), + ), + ), + Positioned( + left: 10 * scale, + top: 6 * scale, + width: 20 * scale, + height: 29 * scale, + child: ClipRRect( + borderRadius: BorderRadius.circular(4 * scale), + child: + item.imageUrl != null || item.asset != null + ? _RocketImage( + asset: item.asset, + imageUrl: item.imageUrl, + fit: BoxFit.cover, + ) + : Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: -5.45 * scale, + top: -10.92 * scale, + width: 30.86 * scale, + height: 66.82 * scale, + child: Image.asset( + _RocketAssets.rocketBody, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + ], + ), + ), + ), + Positioned( + left: 0, + right: 0, + top: 38 * scale, + child: _RocketText( + item.label, + fontSize: 10 * scale, + color: + item.enabled + ? Colors.white + : Colors.white.withValues(alpha: 0.52), + ), + ), + ], + ), + ); + } +} + +class _CrewAvatar extends StatelessWidget { + const _CrewAvatar({required this.scale, required this.member}); + + final double scale; + final RoomRocketCrewMember member; + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + Image.asset( + _RocketAssets.bottomAvatarRing, + width: 40 * scale, + height: 40 * scale, + fit: BoxFit.fill, + ), + ClipOval( + child: SizedBox( + width: 42 * scale, + height: 42 * scale, + child: _RocketImage( + asset: member.asset, + imageUrl: member.imageUrl, + fit: BoxFit.cover, + ), + ), + ), + ], + ); + } +} + +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 _RewardTab extends StatelessWidget { + const _RewardTab({ + required this.scale, + required this.left, + required this.top, + required this.label, + this.active = false, + }); + + final double scale; + final double left; + final double top; + final String label; + final bool active; + + @override + Widget build(BuildContext context) { + final color = + active + ? const Color(0xFF75F3CB) + : const Color(0xFF75F3CB).withValues(alpha: 0.52); + final width = + label == 'In the room' + ? 93.0 + : label == 'Set off' + ? 76.0 + : 68.0; + return Positioned( + left: left * scale, + top: top * scale, + width: width * scale, + height: 14 * scale, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _TabSlash(color: color, scale: scale), + SizedBox(width: 3 * scale), + Flexible( + child: Text( + label, + textScaler: TextScaler.noScaling, + maxLines: 1, + overflow: TextOverflow.visible, + textAlign: TextAlign.center, + style: TextStyle( + color: color, + fontSize: 12 * scale, + height: 1, + decoration: TextDecoration.none, + ), + ), + ), + SizedBox(width: 3 * scale), + Transform.rotate( + angle: math.pi, + child: _TabSlash(color: color, scale: scale), + ), + ], + ), + ); + } +} + +class _TabSlash extends StatelessWidget { + const _TabSlash({required this.color, required this.scale}); + + final Color color; + final double scale; + + @override + Widget build(BuildContext context) { + return Text( + '///', + textScaler: TextScaler.noScaling, + style: TextStyle( + color: color, + fontSize: 10 * scale, + height: 1, + letterSpacing: 0, + decoration: TextDecoration.none, + ), + ); + } +} + +class _NoGlowScrollBehavior extends ScrollBehavior { + const _NoGlowScrollBehavior(); + + @override + Widget buildOverscrollIndicator( + BuildContext context, + Widget child, + ScrollableDetails details, + ) { + return child; + } +} + +class _LevelBadgePainter extends CustomPainter { + const _LevelBadgePainter({required this.selected, required this.enabled}); + + final bool selected; + final bool enabled; + + @override + void paint(Canvas canvas, Size size) { + final path = _scaledPath( + const [ + Offset(35.625, 0.5), + Offset(39.5, 4.39941), + Offset(39.5, 47.5996), + Offset(35.625, 51.5), + Offset(5.18164, 51.5), + Offset(0.5, 47.5732), + Offset(0.5, 4.39941), + Offset(4.375, 0.5), + ], + size, + const Size(40, 52), + ); + canvas.drawPath( + path, + Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF077C60), Color(0xFF08251E)], + ).createShader(Offset.zero & size), + ); + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = selected ? size.width / 22 : size.width / 40 + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + enabled + ? const Color(0xFFB2FBCC) + : const Color(0xFFB2FBCC).withValues(alpha: 0.42), + const Color(0xFFB2FBCC).withValues(alpha: 0), + ], + ).createShader(Offset.zero & size), + ); + } + + @override + bool shouldRepaint(covariant _LevelBadgePainter oldDelegate) { + return oldDelegate.selected != selected || oldDelegate.enabled != enabled; + } +} + +class _RewardCardPainter 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, 126.293), + Offset(88.293, 134.5), + Offset(8.70703, 134.5), + Offset(0.5, 126.293), + Offset(0.5, 8.70703), + Offset(8.70703, 0.5), + ], + size, + const Size(97, 135), + ); + 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(8, 107.367), + Offset(14.5, 101), + Offset(82.5, 101), + Offset(89, 107.367), + Offset(89, 118.633), + Offset(82.5, 125), + Offset(14.5, 125), + Offset(8, 118.633), + ], + size, + const Size(97, 135), + ); + 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), + ); + + final circleCenter = Offset( + size.width * 48.5 / 97, + size.height * 48.5 / 135, + ); + final circleRadius = math.min(size.width / 97, size.height / 135) * 37; + canvas.drawCircle( + circleCenter, + circleRadius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = size.width / 97 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, size.width / 24) + ..color = const Color(0xFFEFFBFF).withValues(alpha: 0.6), + ); + canvas.drawCircle( + circleCenter, + circleRadius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = size.width / 97 + ..color = const Color(0xFF75F3CB), + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +Path _scaledPath(List 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 _RocketText extends StatelessWidget { + const _RocketText( + this.text, { + required this.fontSize, + required this.color, + this.textAlign = TextAlign.center, + }); + + final String text; + final double fontSize; + final Color color; + final TextAlign textAlign; + + @override + Widget build(BuildContext context) { + return Text( + text, + textScaler: TextScaler.noScaling, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: textAlign, + style: TextStyle( + color: color, + fontSize: fontSize, + height: 1, + decoration: TextDecoration.none, + ), + ); + } +} + +class _GradientText extends StatelessWidget { + const _GradientText( + this.text, { + required this.fontSize, + this.fontWeight = FontWeight.w400, + this.textAlign = TextAlign.left, + this.colors = const [Colors.white, Color(0xFFA4FFE3)], + }); + + final String text; + final double fontSize; + final FontWeight fontWeight; + final TextAlign textAlign; + final List colors; + + _GradientText scaled(double scale) { + return _GradientText( + text, + fontSize: fontSize * scale, + fontWeight: fontWeight, + textAlign: textAlign, + colors: colors, + ); + } + + @override + Widget build(BuildContext context) { + return ShaderMask( + shaderCallback: + (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: colors, + ).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 _TimerToken { + const _TimerToken(this.value, this.left); + + final String value; + final double left; +} + +class _RocketAssets { + const _RocketAssets._(); + + static const String rocketStage = 'sc_images/room/rocket/rocket_stage.png'; + static const String rocketBody = 'sc_images/room/rocket/rocket_body.png'; + static const String sidePanel = 'sc_images/room/rocket/side_panel.png'; + static const String bottomStripActive = + 'sc_images/room/rocket/bottom_strip_active.png'; + static const String bottomStripEmpty = + 'sc_images/room/rocket/bottom_strip_active.png'; + static const String bottomTrophy = 'sc_images/room/rocket/bottom_trophy.png'; + static const String backArrow = 'sc_images/room/rocket/back_arrow.png'; + static const String bottomAvatarRing = + 'sc_images/room/rocket/bottom_avatar_ring.png'; + static const String pillBg = 'sc_images/room/rocket/pill_bg.png'; + static const String progressFull = 'sc_images/room/rocket/progress_full.png'; + static const String progressPartial = + 'sc_images/room/rocket/progress_partial.png'; + static const String progressEmpty = + 'sc_images/room/rocket/progress_empty.png'; + static const String rewardTop = 'sc_images/room/rocket/reward_dialog_top.png'; + static const String rewardMiddle = + 'sc_images/room/rocket/reward_dialog_middle.png'; + static const String rewardBottom = + '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; diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart new file mode 100644 index 0000000..5df2daa --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart @@ -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( + 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(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? _noteSections; + + @override + void initState() { + super.initState(); + _status = widget.initialStatus; + unawaited(_load()); + unawaited(_loadRule()); + } + + Future _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( + context, + listen: false, + ).updateRoomRocketConfigurationStatus(res); + } catch (_) {} + } catch (error) { + _rocketUiDebug('status request failed: $error'); + } + } + + Future _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? _rewards; + List? _winners; + bool _loading = true; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + try { + _rocketUiDebug('request reward-popups roomId=${widget.roomId}'); + final res = await SCChatRoomRepository().roomRocketRewardPopups( + widget.roomId, + ); + if (!mounted) { + return; + } + setState(() { + _rewards = RoomRocketApiMapper.rewardItemsFromRecords(res.records); + _winners = RoomRocketApiMapper.winnersFromRecords(res.records); + _loading = false; + }); + } catch (error) { + _rocketUiDebug('reward-popups request failed: $error'); + if (mounted) { + setState(() => _loading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return RoomRocketRewardDialog( + selectedReward: widget.selectedReward, + rewards: _rewards, + winners: _winners, + loadingText: _loading ? 'Loading data......' : '', + useMockFallback: false, + onClose: widget.onClose, + ); + } +} + +class _RoomRocketRecordDialogLoader extends StatefulWidget { + const _RoomRocketRecordDialogLoader({required this.roomId, this.onClose}); + + final String roomId; + final VoidCallback? onClose; + + @override + State<_RoomRocketRecordDialogLoader> createState() => + _RoomRocketRecordDialogLoaderState(); +} + +class _RoomRocketRecordDialogLoaderState + extends State<_RoomRocketRecordDialogLoader> { + List? _records; + bool _loading = true; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _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? initialSections; + final VoidCallback? onClose; + + @override + State<_RoomRocketNoteDialogLoader> createState() => + _RoomRocketNoteDialogLoaderState(); +} + +class _RoomRocketNoteDialogLoaderState + extends State<_RoomRocketNoteDialogLoader> { + List? _sections; + + @override + void initState() { + super.initState(); + _sections = widget.initialSections; + if (_sections != null) { + return; + } + unawaited(_load()); + } + + Future _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'); + } +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart new file mode 100644 index 0000000..5961ec8 --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart @@ -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? 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 _normalizeSections( + SCAppLocalizations? localizations, + List? sections, + ) { + final source = + sections + ?.where( + (item) => + item.title.trim().isNotEmpty || item.body.trim().isNotEmpty, + ) + .toList(growable: false) ?? + const []; + 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.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 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; diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart new file mode 100644 index 0000000..c5b2229 --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_record_dialog.dart @@ -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? 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 _normalizeRecords( + List? records, { + required bool useMockFallback, + }) { + final source = + records + ?.where((item) => item.roomId.trim().isNotEmpty) + .toList(growable: false) ?? + const []; + if (source.isNotEmpty) { + return source; + } + if (!useMockFallback) { + return const []; + } + const reward = RoomRocketRewardItem( + asset: _RecordDialogAssets.rewardThumb, + amount: '13.2k', + ); + return List.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 [], + }); + + final String roomId; + final String levelLabel; + final String time; + final List 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 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; diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart new file mode 100644 index 0000000..ab6d8f4 --- /dev/null +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart @@ -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? rewards; + final List? 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 _normalizeRewards( + RoomRocketRewardItem? selectedReward, + List? rewards, { + required bool useMockFallback, + }) { + final source = + rewards?.where((item) => item.amount.trim().isNotEmpty).toList() ?? + const []; + if (source.isNotEmpty) { + return source; + } + if (!useMockFallback && selectedReward == null) { + return const []; + } + final fallback = + selectedReward ?? + const RoomRocketRewardItem( + asset: _RewardDialogAssets.rewardThumb, + amount: '10000', + ); + return List.generate( + 6, + (_) => fallback, + growable: false, + ); + } + + static List _normalizeWinners( + List? winners, + List rewards, { + required bool useMockFallback, + }) { + final source = + winners + ?.where((item) => item.nickname.trim().isNotEmpty) + .toList(growable: false) ?? + const []; + if (source.isNotEmpty) { + return source; + } + if (!useMockFallback || rewards.isEmpty) { + return const []; + } + 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 _winnerRewards( + List rewards, + ) { + return List.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 [], + }); + + final String nickname; + final String? avatarAsset; + final String? avatarUrl; + final String? frameAsset; + final List rewards; + + RoomRocketRewardWinner withRewards(List 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 rewards; + final List 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.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 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; diff --git a/lib/ui_kit/widgets/room/room_play_widget.dart b/lib/ui_kit/widgets/room/room_play_widget.dart index 6dd0220..f41879e 100644 --- a/lib/ui_kit/widgets/room/room_play_widget.dart +++ b/lib/ui_kit/widgets/room/room_play_widget.dart @@ -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 { 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, diff --git a/pubspec.yaml b/pubspec.yaml index 2bbd8b0..d57aa39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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/ diff --git a/sc_images/room/cp_invite/sc_cp_invite_accept_btn.png b/sc_images/room/cp_invite/sc_cp_invite_accept_btn.png new file mode 100644 index 0000000..cbdfbdb Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_accept_btn.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_avatar_frame.png b/sc_images/room/cp_invite/sc_cp_invite_avatar_frame.png new file mode 100644 index 0000000..b0fae19 Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_avatar_frame.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_background.png b/sc_images/room/cp_invite/sc_cp_invite_background.png new file mode 100644 index 0000000..af2e231 Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_background.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_close.png b/sc_images/room/cp_invite/sc_cp_invite_close.png new file mode 100644 index 0000000..90d5dbc Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_close.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_countdown.png b/sc_images/room/cp_invite/sc_cp_invite_countdown.png new file mode 100644 index 0000000..df08777 Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_countdown.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_divider.png b/sc_images/room/cp_invite/sc_cp_invite_divider.png new file mode 100644 index 0000000..5864e06 Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_divider.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_gift.png b/sc_images/room/cp_invite/sc_cp_invite_gift.png new file mode 100644 index 0000000..3f0c73e Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_gift.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_gift_burst.png b/sc_images/room/cp_invite/sc_cp_invite_gift_burst.png new file mode 100644 index 0000000..7fd71c1 Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_gift_burst.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_gift_floor.png b/sc_images/room/cp_invite/sc_cp_invite_gift_floor.png new file mode 100644 index 0000000..2759b54 Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_gift_floor.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_panel.png b/sc_images/room/cp_invite/sc_cp_invite_panel.png new file mode 100644 index 0000000..591fc10 Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_panel.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_reject_btn.png b/sc_images/room/cp_invite/sc_cp_invite_reject_btn.png new file mode 100644 index 0000000..dabb93c Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_reject_btn.png differ diff --git a/sc_images/room/cp_invite/sc_cp_invite_title.png b/sc_images/room/cp_invite/sc_cp_invite_title.png new file mode 100644 index 0000000..a8dfbac Binary files /dev/null and b/sc_images/room/cp_invite/sc_cp_invite_title.png differ diff --git a/sc_images/room/cp_progress/sc_cp_progress_avatar_frame.png b/sc_images/room/cp_progress/sc_cp_progress_avatar_frame.png new file mode 100644 index 0000000..5bfa13c Binary files /dev/null and b/sc_images/room/cp_progress/sc_cp_progress_avatar_frame.png differ diff --git a/sc_images/room/cp_progress/sc_cp_progress_bar_bg.png b/sc_images/room/cp_progress/sc_cp_progress_bar_bg.png new file mode 100644 index 0000000..b9ce504 Binary files /dev/null and b/sc_images/room/cp_progress/sc_cp_progress_bar_bg.png differ diff --git a/sc_images/room/cp_progress/sc_cp_progress_bar_fill.png b/sc_images/room/cp_progress/sc_cp_progress_bar_fill.png new file mode 100644 index 0000000..aac79a1 Binary files /dev/null and b/sc_images/room/cp_progress/sc_cp_progress_bar_fill.png differ diff --git a/sc_images/room/cp_progress/sc_cp_progress_cancel_btn.png b/sc_images/room/cp_progress/sc_cp_progress_cancel_btn.png new file mode 100644 index 0000000..e98e4af Binary files /dev/null and b/sc_images/room/cp_progress/sc_cp_progress_cancel_btn.png differ diff --git a/sc_images/room/cp_progress/sc_cp_progress_dialog_bg.png b/sc_images/room/cp_progress/sc_cp_progress_dialog_bg.png new file mode 100644 index 0000000..502c329 Binary files /dev/null and b/sc_images/room/cp_progress/sc_cp_progress_dialog_bg.png differ diff --git a/sc_images/room/cp_progress/sc_cp_progress_header.png b/sc_images/room/cp_progress/sc_cp_progress_header.png new file mode 100644 index 0000000..6ae78b1 Binary files /dev/null and b/sc_images/room/cp_progress/sc_cp_progress_header.png differ diff --git a/sc_images/room/rocket/back_arrow.png b/sc_images/room/rocket/back_arrow.png new file mode 100644 index 0000000..35aec77 Binary files /dev/null and b/sc_images/room/rocket/back_arrow.png differ diff --git a/sc_images/room/rocket/bottom_avatar_ring.png b/sc_images/room/rocket/bottom_avatar_ring.png new file mode 100644 index 0000000..32a7a41 Binary files /dev/null and b/sc_images/room/rocket/bottom_avatar_ring.png differ diff --git a/sc_images/room/rocket/bottom_strip_active.png b/sc_images/room/rocket/bottom_strip_active.png new file mode 100644 index 0000000..71ae137 Binary files /dev/null and b/sc_images/room/rocket/bottom_strip_active.png differ diff --git a/sc_images/room/rocket/bottom_trophy.png b/sc_images/room/rocket/bottom_trophy.png new file mode 100644 index 0000000..06afee0 Binary files /dev/null and b/sc_images/room/rocket/bottom_trophy.png differ diff --git a/sc_images/room/rocket/coin.png b/sc_images/room/rocket/coin.png new file mode 100644 index 0000000..0dd3d72 Binary files /dev/null and b/sc_images/room/rocket/coin.png differ diff --git a/sc_images/room/rocket/pill_bg.png b/sc_images/room/rocket/pill_bg.png new file mode 100644 index 0000000..dae87d2 Binary files /dev/null and b/sc_images/room/rocket/pill_bg.png differ diff --git a/sc_images/room/rocket/progress_empty.png b/sc_images/room/rocket/progress_empty.png new file mode 100644 index 0000000..8dd2480 Binary files /dev/null and b/sc_images/room/rocket/progress_empty.png differ diff --git a/sc_images/room/rocket/progress_full.png b/sc_images/room/rocket/progress_full.png new file mode 100644 index 0000000..7485d1e Binary files /dev/null and b/sc_images/room/rocket/progress_full.png differ diff --git a/sc_images/room/rocket/progress_partial.png b/sc_images/room/rocket/progress_partial.png new file mode 100644 index 0000000..2f24ded Binary files /dev/null and b/sc_images/room/rocket/progress_partial.png differ diff --git a/sc_images/room/rocket/reward_dialog_bottom.png b/sc_images/room/rocket/reward_dialog_bottom.png new file mode 100644 index 0000000..a6589e0 Binary files /dev/null and b/sc_images/room/rocket/reward_dialog_bottom.png differ diff --git a/sc_images/room/rocket/reward_dialog_middle.png b/sc_images/room/rocket/reward_dialog_middle.png new file mode 100644 index 0000000..3bdca32 Binary files /dev/null and b/sc_images/room/rocket/reward_dialog_middle.png differ diff --git a/sc_images/room/rocket/reward_dialog_top.png b/sc_images/room/rocket/reward_dialog_top.png new file mode 100644 index 0000000..9bcb6cb Binary files /dev/null and b/sc_images/room/rocket/reward_dialog_top.png differ diff --git a/sc_images/room/rocket/reward_thumb.png b/sc_images/room/rocket/reward_thumb.png new file mode 100644 index 0000000..d1d152e Binary files /dev/null and b/sc_images/room/rocket/reward_thumb.png differ diff --git a/sc_images/room/rocket/rocket_body.png b/sc_images/room/rocket/rocket_body.png new file mode 100644 index 0000000..7b2615d Binary files /dev/null and b/sc_images/room/rocket/rocket_body.png differ diff --git a/sc_images/room/rocket/rocket_rank_bg.png b/sc_images/room/rocket/rocket_rank_bg.png new file mode 100644 index 0000000..f78c5f8 Binary files /dev/null and b/sc_images/room/rocket/rocket_rank_bg.png differ diff --git a/sc_images/room/rocket/rocket_rank_frame_1.png b/sc_images/room/rocket/rocket_rank_frame_1.png new file mode 100644 index 0000000..1c63e87 Binary files /dev/null and b/sc_images/room/rocket/rocket_rank_frame_1.png differ diff --git a/sc_images/room/rocket/rocket_rank_frame_2.png b/sc_images/room/rocket/rocket_rank_frame_2.png new file mode 100644 index 0000000..e00b622 Binary files /dev/null and b/sc_images/room/rocket/rocket_rank_frame_2.png differ diff --git a/sc_images/room/rocket/rocket_rank_frame_3.png b/sc_images/room/rocket/rocket_rank_frame_3.png new file mode 100644 index 0000000..b28f2a2 Binary files /dev/null and b/sc_images/room/rocket/rocket_rank_frame_3.png differ diff --git a/sc_images/room/rocket/rocket_stage.png b/sc_images/room/rocket/rocket_stage.png new file mode 100644 index 0000000..869eb09 Binary files /dev/null and b/sc_images/room/rocket/rocket_stage.png differ diff --git a/sc_images/room/rocket/side_panel.png b/sc_images/room/rocket/side_panel.png new file mode 100644 index 0000000..359c6ba Binary files /dev/null and b/sc_images/room/rocket/side_panel.png differ diff --git a/tool/room_cp_invite_preview.dart b/tool/room_cp_invite_preview.dart new file mode 100644 index 0000000..20e4b6c --- /dev/null +++ b/tool/room_cp_invite_preview.dart @@ -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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/需求进度.md b/需求进度.md index ecd7676..ead1971 100644 --- a/需求进度.md +++ b/需求进度.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 进度弹窗修改 + +--- + # 需求进度 ## 当前总目标