diff --git a/lib/app/config/configs/sc_variant1_config.dart b/lib/app/config/configs/sc_variant1_config.dart index df2d94b..221ce48 100644 --- a/lib/app/config/configs/sc_variant1_config.dart +++ b/lib/app/config/configs/sc_variant1_config.dart @@ -16,7 +16,7 @@ class SCVariant1Config implements AppConfig { @override String get apiHost => const String.fromEnvironment( 'API_HOST', - defaultValue: 'http://192.168.110.66:1100/', + defaultValue: 'http://192.168.110.64:1100/', ); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 本地“http://192.168.110.66:1100/”,线上“https://jvapi.haiyihy.com/” @override diff --git a/lib/debug/first_recharge_dialog_preview.dart b/lib/debug/first_recharge_dialog_preview.dart new file mode 100644 index 0000000..af645db --- /dev/null +++ b/lib/debug/first_recharge_dialog_preview.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:flutter/services.dart'; +import 'package:yumi/ui_kit/widgets/first_recharge/first_recharge_dialog.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + runApp(const FirstRechargeDialogPreviewApp()); +} + +class FirstRechargeDialogPreviewApp extends StatelessWidget { + const FirstRechargeDialogPreviewApp({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 _FirstRechargeDialogPreviewPage(), + ), + ); + } +} + +class _FirstRechargeDialogPreviewPage extends StatefulWidget { + const _FirstRechargeDialogPreviewPage(); + + @override + State<_FirstRechargeDialogPreviewPage> createState() => + _FirstRechargeDialogPreviewPageState(); +} + +class _FirstRechargeDialogPreviewPageState + extends State<_FirstRechargeDialogPreviewPage> { + bool _shown = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_shown) { + return; + } + _shown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + FirstRechargeDialog.show( + context, + data: FirstRechargeDialogData.mock(), + onBuyNow: (_) {}, + ); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Image.asset( + 'sc_images/first_recharge/preview_background.png', + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + ); + } +} diff --git a/lib/debug/gift_page_preview.dart b/lib/debug/gift_page_preview.dart new file mode 100644 index 0000000..0d4ae83 --- /dev/null +++ b/lib/debug/gift_page_preview.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.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/app/config/app_config.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/main.dart'; +import 'package:yumi/modules/gift/gift_page.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/services/general/sc_app_general_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; +import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; + +Future main() async { + AppConfig.initialize(); + WidgetsFlutterBinding.ensureInitialized(); + await DataPersistence.initialize(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + runApp(const GiftPagePreviewApp()); +} + +class GiftPagePreviewApp extends StatelessWidget { + const GiftPagePreviewApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => _PreviewAppGeneralManager(), + ), + ChangeNotifierProvider(create: (_) => RtcProvider()), + ChangeNotifierProvider(create: (_) => RtmProvider()), + ChangeNotifierProvider( + create: (_) => _PreviewUserProfileManager(), + ), + ], + child: ScreenUtilInit( + designSize: const Size(375, 812), + minTextAdapt: true, + splitScreenMode: true, + builder: + (_, child) => MaterialApp( + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + locale: const Locale('en'), + supportedLocales: SCAppLocalizations.supportedLocales, + localizationsDelegates: const [ + SCAppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + builder: FlutterSmartDialog.init(), + home: const _GiftPagePreviewHome(), + ), + ), + ); + } +} + +class _GiftPagePreviewHome extends StatefulWidget { + const _GiftPagePreviewHome(); + + @override + State<_GiftPagePreviewHome> createState() => _GiftPagePreviewHomeState(); +} + +class _GiftPagePreviewHomeState extends State<_GiftPagePreviewHome> { + bool _shown = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_shown) { + return; + } + _shown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + SmartDialog.show( + tag: "showGiftControl", + alignment: Alignment.bottomCenter, + maskColor: Colors.transparent, + animationType: SmartAnimationType.fade, + clickMaskDismiss: true, + builder: (_) => const GiftPage(), + ); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + Image.asset( + 'sc_images/room/sc_icon_room_defaut_bg.png', + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => const ColoredBox(color: Color(0xFF062320)), + ), + const Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only(top: 88), + child: Text( + 'Gift panel preview', + style: TextStyle(color: Colors.white70, fontSize: 16), + ), + ), + ), + ], + ), + ); + } +} + +class _PreviewAppGeneralManager extends SCAppGeneralManager { + _PreviewAppGeneralManager() { + final gifts = List.generate( + 8, + (index) => SocialChatGiftRes( + id: 'preview_$index', + giftName: 'Gift ${index + 1}', + giftCandy: (index + 1) * 10, + giftTab: 'ALL', + giftPhoto: '', + ), + ); + giftResList = gifts; + giftByTab = {'ALL': gifts}; + } + + @override + Future fetchCountryList() async {} + + @override + Future giftList({ + bool includeCustomized = true, + bool forceRefresh = false, + }) async {} + + @override + void giftActivityList() {} + + @override + Future giftBackpack({bool forceRefresh = false}) async {} + + @override + Future warmGiftPageCovers( + String type, { + required int pageIndex, + int pageCount = 1, + int pageSize = 8, + }) async {} + + @override + bool isGiftTabLoading(String type) => false; +} + +class _PreviewUserProfileManager extends SocialChatUserProfileManager { + @override + void balance() {} +} diff --git a/lib/debug/room_animation_switch_dialog_preview.dart b/lib/debug/room_animation_switch_dialog_preview.dart new file mode 100644 index 0000000..349a456 --- /dev/null +++ b/lib/debug/room_animation_switch_dialog_preview.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/ui_kit/widgets/room/room_animation_switch_dialog.dart'; + +void main() { + SCGlobalConfig.isGiftSpecialEffects = true; + SCGlobalConfig.isLuckGiftSpecialEffects = true; + SCGlobalConfig.isEntryVehicleAnimation = false; + runApp(const RoomAnimationSwitchDialogPreviewApp()); +} + +class RoomAnimationSwitchDialogPreviewApp extends StatelessWidget { + const RoomAnimationSwitchDialogPreviewApp({super.key}); + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + designSize: const Size(375, 812), + minTextAdapt: true, + splitScreenMode: true, + builder: + (_, child) => MaterialApp( + debugShowCheckedModeBanner: false, + locale: const Locale('en'), + supportedLocales: SCAppLocalizations.supportedLocales, + localizationsDelegates: const [ + SCAppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + builder: FlutterSmartDialog.init(), + home: const _RoomAnimationSwitchDialogPreviewPage(), + ), + ); + } +} + +class _RoomAnimationSwitchDialogPreviewPage extends StatelessWidget { + const _RoomAnimationSwitchDialogPreviewPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: const [ + _PreviewRoomBackground(), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: RoomAnimationSwitchDialog(), + ), + ], + ), + ); + } +} + +class _PreviewRoomBackground extends StatelessWidget { + const _PreviewRoomBackground(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF172F35), Color(0xFF071B17), Color(0xFF1B1308)], + ), + ), + child: Stack( + children: [ + Positioned( + left: 24.w, + top: 70.w, + child: _PreviewLine(width: 128.w, opacity: 0.55), + ), + for (var index = 0; index < 10; index++) + Positioned( + left: (38 + (index % 5) * 66).w, + top: (145 + (index ~/ 5) * 80).w, + child: const _PreviewSeat(), + ), + Positioned( + left: 26.w, + right: 26.w, + top: 355.w, + child: _PreviewPanel(height: 150.w), + ), + Positioned( + left: 24.w, + right: 24.w, + bottom: 36.w, + child: _PreviewPanel(height: 52.w), + ), + ], + ), + ); + } +} + +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.36), + width: 2.w, + ), + color: Colors.black.withValues(alpha: 0.18), + ), + 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 Container( + width: width, + height: 14.w, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: opacity), + borderRadius: BorderRadius.circular(7.w), + ), + ); + } +} + +class _PreviewPanel extends StatelessWidget { + const _PreviewPanel({required this.height}); + + final double height; + + @override + Widget build(BuildContext context) { + return Container( + height: height, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.24), + borderRadius: BorderRadius.circular(16.w), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + ); + } +} diff --git a/lib/debug/room_recharge_bottom_sheet_preview.dart b/lib/debug/room_recharge_bottom_sheet_preview.dart new file mode 100644 index 0000000..af314b6 --- /dev/null +++ b/lib/debug/room_recharge_bottom_sheet_preview.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.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/app_localizations.dart'; +import 'package:yumi/main.dart'; +import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/services/payment/google_payment_manager.dart'; +import 'package:yumi/ui_kit/widgets/room/room_recharge_bottom_sheet.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + runApp(const RoomRechargeBottomSheetPreviewApp()); +} + +class RoomRechargeBottomSheetPreviewApp extends StatelessWidget { + const RoomRechargeBottomSheetPreviewApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => _PreviewUserProfileManager(), + ), + ChangeNotifierProvider( + create: (_) => AndroidPaymentProcessor(), + ), + ], + child: ScreenUtilInit( + designSize: const Size(375, 812), + minTextAdapt: true, + splitScreenMode: true, + builder: + (_, child) => MaterialApp( + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + locale: const Locale('en'), + supportedLocales: SCAppLocalizations.supportedLocales, + localizationsDelegates: const [ + SCAppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + builder: FlutterSmartDialog.init(), + home: const _RoomRechargeBottomSheetPreviewPage(), + ), + ), + ); + } +} + +class _RoomRechargeBottomSheetPreviewPage extends StatefulWidget { + const _RoomRechargeBottomSheetPreviewPage(); + + @override + State<_RoomRechargeBottomSheetPreviewPage> createState() => + _RoomRechargeBottomSheetPreviewPageState(); +} + +class _RoomRechargeBottomSheetPreviewPageState + extends State<_RoomRechargeBottomSheetPreviewPage> { + bool _shown = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_shown) { + return; + } + _shown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + RoomRechargeBottomSheet.show(context); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Image.asset( + 'sc_images/room/sc_icon_room_defaut_bg.png', + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + ); + } +} + +class _PreviewUserProfileManager extends SocialChatUserProfileManager { + @override + void balance() { + updateBalance(0); + } +} diff --git a/lib/main.dart b/lib/main.dart index db97f9c..fa642c2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -276,6 +276,8 @@ class RootAppWithProviders extends StatelessWidget { final GlobalKey navigatorKey = GlobalKey(); final RouteObserver routeObserver = RouteObserver(); +final RouteObserver> modalRouteObserver = + RouteObserver>(); class YumiApplication extends StatefulWidget { const YumiApplication({super.key}); @@ -429,7 +431,7 @@ class _YumiApplicationState extends State ); }, ), - navigatorObservers: [routeObserver], + navigatorObservers: [routeObserver, modalRouteObserver], ), ), ); diff --git a/lib/modules/chat/message_chat_page.dart b/lib/modules/chat/message_chat_page.dart index e97d52a..9462608 100644 --- a/lib/modules/chat/message_chat_page.dart +++ b/lib/modules/chat/message_chat_page.dart @@ -1663,6 +1663,26 @@ class _MessageItem extends StatelessWidget { inviter: RoomCpInviteDialogUser( name: inviterName, avatarUrl: cpInvite?.userAvatar ?? "", + headdress: + isInviter + ? currentProfile?.getHeaddress()?.sourceUrl ?? "" + : _firstNonBlankString([ + cpInvite?.userHeaddress, + data["userHeaddress"]?.toString(), + data["userAvatarFrame"]?.toString(), + content["userHeaddress"]?.toString(), + content["userAvatarFrame"]?.toString(), + ], fallback: ""), + headdressCover: + isInviter + ? currentProfile?.getHeaddress()?.cover ?? "" + : _firstNonBlankString([ + cpInvite?.userHeaddressCover, + data["userHeaddressCover"]?.toString(), + data["userAvatarFrameCover"]?.toString(), + content["userHeaddressCover"]?.toString(), + content["userAvatarFrameCover"]?.toString(), + ], fallback: ""), ), receiver: RoomCpInviteDialogUser( name: receiverName, @@ -1673,6 +1693,24 @@ class _MessageItem extends StatelessWidget { content["acceptUserAvatar"]?.toString(), ], fallback: "") : currentProfile?.userAvatar ?? "", + headdress: + isInviter + ? _firstNonBlankString([ + data["acceptHeaddress"]?.toString(), + data["acceptAvatarFrame"]?.toString(), + content["acceptHeaddress"]?.toString(), + content["acceptAvatarFrame"]?.toString(), + ], fallback: "") + : currentProfile?.getHeaddress()?.sourceUrl ?? "", + headdressCover: + isInviter + ? _firstNonBlankString([ + data["acceptHeaddressCover"]?.toString(), + data["acceptAvatarFrameCover"]?.toString(), + content["acceptHeaddressCover"]?.toString(), + content["acceptAvatarFrameCover"]?.toString(), + ], fallback: "") + : currentProfile?.getHeaddress()?.cover ?? "", ), style: isInviter diff --git a/lib/modules/chat/system/message_system_page.dart b/lib/modules/chat/system/message_system_page.dart index 3b317c7..ead845d 100644 --- a/lib/modules/chat/system/message_system_page.dart +++ b/lib/modules/chat/system/message_system_page.dart @@ -759,6 +759,26 @@ class _MessageItem extends StatelessWidget { inviter: RoomCpInviteDialogUser( name: inviterName, avatarUrl: cpInvite?.userAvatar ?? "", + headdress: + isInviter + ? currentProfile?.getHeaddress()?.sourceUrl ?? "" + : _firstNonBlankString([ + cpInvite?.userHeaddress, + data["userHeaddress"]?.toString(), + data["userAvatarFrame"]?.toString(), + content["userHeaddress"]?.toString(), + content["userAvatarFrame"]?.toString(), + ], fallback: ""), + headdressCover: + isInviter + ? currentProfile?.getHeaddress()?.cover ?? "" + : _firstNonBlankString([ + cpInvite?.userHeaddressCover, + data["userHeaddressCover"]?.toString(), + data["userAvatarFrameCover"]?.toString(), + content["userHeaddressCover"]?.toString(), + content["userAvatarFrameCover"]?.toString(), + ], fallback: ""), ), receiver: RoomCpInviteDialogUser( name: receiverName, @@ -769,6 +789,24 @@ class _MessageItem extends StatelessWidget { content["acceptUserAvatar"]?.toString(), ], fallback: "") : currentProfile?.userAvatar ?? "", + headdress: + isInviter + ? _firstNonBlankString([ + data["acceptHeaddress"]?.toString(), + data["acceptAvatarFrame"]?.toString(), + content["acceptHeaddress"]?.toString(), + content["acceptAvatarFrame"]?.toString(), + ], fallback: "") + : currentProfile?.getHeaddress()?.sourceUrl ?? "", + headdressCover: + isInviter + ? _firstNonBlankString([ + data["acceptHeaddressCover"]?.toString(), + data["acceptAvatarFrameCover"]?.toString(), + content["acceptHeaddressCover"]?.toString(), + content["acceptAvatarFrameCover"]?.toString(), + ], fallback: "") + : currentProfile?.getHeaddress()?.cover ?? "", ), style: isInviter diff --git a/lib/modules/gift/cp_rights/cp_rights_guide_page.dart b/lib/modules/gift/cp_rights/cp_rights_guide_page.dart index 1faea6f..6c15fba 100644 --- a/lib/modules/gift/cp_rights/cp_rights_guide_page.dart +++ b/lib/modules/gift/cp_rights/cp_rights_guide_page.dart @@ -10,6 +10,7 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; enum _CpGuideTab { note, rights } @@ -40,6 +41,7 @@ class _CpRightsGuidePageState extends State { final Map<_CpRightsCategory, List<_CpRightsLevel>> _rightsRowsByCategory = {}; final Set<_CpRightsCategory> _loadingRightsCategories = {}; final Set<_CpRightsCategory> _loadedRightsCategories = {}; + final Set<_CpRightsCategory> _failedRightsCategories = {}; @override void initState() { @@ -388,6 +390,9 @@ class _CpRightsGuidePageState extends State { final isInitialLoading = _loadingRightsCategories.contains(_rightsCategory) && !_rightsRowsByCategory.containsKey(_rightsCategory); + final hasLoadError = + _failedRightsCategories.contains(_rightsCategory) && + !_rightsRowsByCategory.containsKey(_rightsCategory); return Column( children: [ Padding( @@ -402,21 +407,24 @@ class _CpRightsGuidePageState extends State { ), ), SizedBox(height: 13.w), - Stack( - alignment: Alignment.center, - children: [ - _buildRightsTable(rows), - if (isInitialLoading) - SizedBox( - width: 22.w, - height: 22.w, - child: CircularProgressIndicator( - strokeWidth: 2.w, - color: _accentColor, + if (hasLoadError) + _buildRightsLoadError(_rightsCategory) + else + Stack( + alignment: Alignment.center, + children: [ + _buildRightsTable(rows), + if (isInitialLoading) + SizedBox( + width: 22.w, + height: 22.w, + child: CircularProgressIndicator( + strokeWidth: 2.w, + color: _accentColor, + ), ), - ), - ], - ), + ], + ), ], ); } @@ -461,23 +469,54 @@ class _CpRightsGuidePageState extends State { ); } + Widget _buildRightsLoadError(_CpRightsCategory category) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _ensureRightsConfigLoaded(category, force: true), + child: Container( + width: 351.w, + height: 156.w, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12.w), + ), + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Text( + "Failed to load rights. Tap to retry.", + textAlign: TextAlign.center, + textScaler: TextScaler.noScaling, + style: _bodyStyle(color: _accentColor, lineHeight: 1.3), + ), + ), + ); + } + List<_CpRightsLevel> _rightsRowsFor(_CpRightsCategory category) { switch (category) { case _CpRightsCategory.cp: return const [ - _CpRightsLevel(level: "Lv.1", intimacy: "0", rightSlots: 2), - _CpRightsLevel(level: "Lv.2", intimacy: "1,000,000", rightSlots: 3), - _CpRightsLevel(level: "Lv.3", intimacy: "2,000,000", rightSlots: 3), + _CpRightsLevel(level: "Lv.1", intimacy: "--"), + _CpRightsLevel(level: "Lv.2", intimacy: "--"), + _CpRightsLevel(level: "Lv.3", intimacy: "--"), + _CpRightsLevel(level: "Lv.4", intimacy: "--"), + _CpRightsLevel(level: "Lv.5", intimacy: "--"), ]; case _CpRightsCategory.brother: return const [ - _CpRightsLevel(level: "Lv.1", intimacy: "0", rightSlots: 2), - _CpRightsLevel(level: "Lv.2", intimacy: "800,000", rightSlots: 2), + _CpRightsLevel(level: "Lv.1", intimacy: "--"), + _CpRightsLevel(level: "Lv.2", intimacy: "--"), + _CpRightsLevel(level: "Lv.3", intimacy: "--"), + _CpRightsLevel(level: "Lv.4", intimacy: "--"), + _CpRightsLevel(level: "Lv.5", intimacy: "--"), ]; case _CpRightsCategory.sisters: return const [ - _CpRightsLevel(level: "Lv.1", intimacy: "0", rightSlots: 2), - _CpRightsLevel(level: "Lv.2", intimacy: "800,000", rightSlots: 2), + _CpRightsLevel(level: "Lv.1", intimacy: "--"), + _CpRightsLevel(level: "Lv.2", intimacy: "--"), + _CpRightsLevel(level: "Lv.3", intimacy: "--"), + _CpRightsLevel(level: "Lv.4", intimacy: "--"), + _CpRightsLevel(level: "Lv.5", intimacy: "--"), ]; } } @@ -496,7 +535,10 @@ class _CpRightsGuidePageState extends State { return; } final relationType = _relationTypeForRightsCategory(category); - setState(() => _loadingRightsCategories.add(category)); + setState(() { + _loadingRightsCategories.add(category); + _failedRightsCategories.remove(category); + }); _rightsDebugLog( 'load start relationType=$relationType cpUserId=${widget.cpUserId}', ); @@ -506,12 +548,16 @@ class _CpRightsGuidePageState extends State { cpUserId: widget.cpUserId, ); final rows = _rightsRowsFromConfig(config, category); + if (rows.isEmpty) { + throw StateError('empty CP rights levels'); + } if (!mounted) { return; } setState(() { _rightsRowsByCategory[category] = rows; _loadedRightsCategories.add(category); + _failedRightsCategories.remove(category); _loadingRightsCategories.remove(category); }); _rightsDebugLog( @@ -521,7 +567,10 @@ class _CpRightsGuidePageState extends State { if (!mounted) { return; } - setState(() => _loadingRightsCategories.remove(category)); + setState(() { + _loadingRightsCategories.remove(category); + _failedRightsCategories.add(category); + }); _rightsDebugLog('load failed relationType=$relationType error=$error'); } } @@ -538,17 +587,17 @@ class _CpRightsGuidePageState extends State { rows.add( _CpRightsLevel( level: - levelNumber != null - ? "Lv.$levelNumber" - : (levelName?.isNotEmpty == true - ? levelName! + levelName?.isNotEmpty == true + ? levelName! + : (levelNumber != null + ? "Lv.$levelNumber" : "Lv.${rows.length + 1}"), intimacy: _formatRightsIntimacyValue(requiredValue), - rightSlots: level.rights.isEmpty ? 1 : level.rights.length, + rights: level.rights.map(_CpRightItem.fromRes).toList(), ), ); } - return rows.isEmpty ? _rightsRowsFor(category) : rows; + return rows; } String _relationTypeForRightsCategory(_CpRightsCategory category) { @@ -679,7 +728,11 @@ class _CpRightsGuidePageState extends State { for (var i = 0; i < row.rightSlots; i++) ...[ SizedBox( height: rightCellHeight.w, - child: Center(child: _buildRightPlaceholder()), + child: Center( + child: _buildRightCell( + i < row.rights.length ? row.rights[i] : null, + ), + ), ), if (i != row.rightSlots - 1) _tableLine(width: 117), ], @@ -715,7 +768,47 @@ class _CpRightsGuidePageState extends State { return Container(width: width.w, height: 1.w, color: Colors.white); } - Widget _buildRightPlaceholder() { + Widget _buildRightCell(_CpRightItem? right) { + final opacity = (right?.unlocked ?? false) ? 1.0 : 0.55; + final title = right?.title.trim() ?? ""; + return Opacity( + opacity: opacity, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildRightIcon(right?.icon ?? ""), + if (title.isNotEmpty) ...[ + SizedBox(height: 4.w), + Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + textScaler: TextScaler.noScaling, + style: _bodyStyle(fontSize: 10, lineHeight: 1.1), + ), + ), + ], + ], + ), + ); + } + + Widget _buildRightIcon(String icon) { + if (icon.trim().isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(9.w), + child: netImage( + url: icon, + width: 42.w, + height: 42.w, + fit: BoxFit.cover, + noDefaultImg: true, + ), + ); + } return Container( width: 42.w, height: 42.w, @@ -915,10 +1008,32 @@ class _CpRightsLevel { const _CpRightsLevel({ required this.level, required this.intimacy, - required this.rightSlots, + this.rights = const [], }); final String level; final String intimacy; - final int rightSlots; + final List<_CpRightItem> rights; + + int get rightSlots => rights.isEmpty ? 1 : rights.length; +} + +class _CpRightItem { + const _CpRightItem({ + required this.icon, + required this.title, + required this.unlocked, + }); + + factory _CpRightItem.fromRes(SCCpRightRes right) { + return _CpRightItem( + icon: right.icon?.trim() ?? "", + title: right.title?.trim() ?? "", + unlocked: right.unlocked ?? false, + ); + } + + final String icon; + final String title; + final bool unlocked; } diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index 14afcd3..1b8543c 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -22,6 +22,7 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_system_invit_message_res.dart'; +import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; import 'package:yumi/main.dart'; import 'package:provider/provider.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; @@ -35,13 +36,13 @@ import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; -import 'package:yumi/ui_kit/widgets/countdown_timer.dart'; import 'package:yumi/ui_kit/widgets/gift/sc_gift_combo_send_button.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:yumi/modules/wallet/wallet_route.dart'; import 'package:yumi/modules/gift/gift_tab_page.dart'; import 'package:yumi/modules/gift/cp_rights/cp_rights_guide_page.dart'; import 'package:yumi/services/gift/room_gift_combo_send_controller.dart'; +import 'package:yumi/ui_kit/widgets/room/room_recharge_bottom_sheet.dart'; import '../../shared/data_sources/models/enum/sc_gift_type.dart'; import '../../shared/data_sources/models/message/sc_floating_message.dart'; import '../../shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart'; @@ -65,6 +66,8 @@ class GiftPage extends StatefulWidget { class _GiftPageState extends State with TickerProviderStateMixin { static const String _cpProfileAssetBase = "sc_images/room/cp_profile"; + static const String _firstRechargeGiftEntryCard = + "sc_images/first_recharge/room_gift_entry_card.png"; static const String _backpackGiftTab = SCAppGeneralManager.backpackGiftTab; static const Duration _comboSendBatchWindow = Duration(milliseconds: 200); @@ -280,10 +283,12 @@ class _GiftPageState extends State with TickerProviderStateMixin { return _isCpGiftTab(_tabTypes[controller.index]); } - bool _isCpGiftTab(String tabType) => - tabType.trim().toUpperCase() == SCGiftType.CP.name; + bool _isCpGiftTab(String tabType) => scIsCpGiftTab(tabType); - bool _isCpGift(SocialChatGiftRes? gift) => _isCpGiftTab(gift?.giftTab ?? ""); + bool _isCpGift(SocialChatGiftRes? gift) => scIsCpGift(gift); + + String _cpRelationTypeForGift(SocialChatGiftRes gift) => + scCpRelationTypeForGift(gift); bool get _isCpRecipientSingleMode => _isCurrentCpGiftTab || _isCpGift(checkedGift); @@ -640,6 +645,19 @@ class _GiftPageState extends State with TickerProviderStateMixin { return errorText.contains('INSUFFICIENT_BALANCE'); } + bool _isInsufficientMoneyError(Object error, String resolvedMessage) { + final errorText = + '${_readBackendErrorText(error)} $resolvedMessage'.toLowerCase(); + return errorText.contains('no enough money') || + errorText.contains('not enough money') || + errorText.contains('insufficient money') || + errorText.contains('insufficient balance') || + errorText.contains('insufficient_balance') || + errorText.contains('balance not enough') || + errorText.contains('not enough gold') || + errorText.contains('insufficient gold'); + } + String _readBackendErrorText(Object error) { final values = []; void addValue(dynamic value) { @@ -684,6 +702,11 @@ class _GiftPageState extends State with TickerProviderStateMixin { } final currentTabType = giftTabs[tabController.index].type; final isCpTab = currentTabType == "CP"; + final showFirstRechargeEntry = + !SCGlobalConfig.isReview && + !isCpTab && + (AccountStorage().getCurrentUser()?.userProfile?.firstRecharge ?? + false); _ensureCurrentTabSelection(ref, giftTabs[tabController.index].type); final isCpRecipientSingleMode = isCpTab || _isCpGift(checkedGift); return SafeArea( @@ -691,83 +714,14 @@ class _GiftPageState extends State with TickerProviderStateMixin { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SCGlobalConfig.isReview - ? Container() - : ((AccountStorage() - .getCurrentUser() - ?.userProfile - ?.firstRecharge ?? - false) - ? GestureDetector( - child: Stack( - alignment: AlignmentDirectional.bottomEnd, - children: [ - Transform.flip( - flipX: - SCGlobalConfig.lang == "ar" - ? true - : false, // 水平翻转 - flipY: false, // 垂直翻转设为 false - child: Image.asset( - _strategy - .getGiftPageFirstRechargeRoomTagIcon(), - height: 75.w, - ), - ), - SCGlobalConfig.lang == "ar" - ? PositionedDirectional( - end: 22.w, - bottom: 38.w, - child: Image.asset( - _strategy - .getGiftPageFirstRechargeTextIcon( - 'ar', - ), - height: 13.w, - ), - ) - : PositionedDirectional( - end: 16.w, - bottom: 38.w, - child: Image.asset( - _strategy - .getGiftPageFirstRechargeTextIcon( - 'en', - ), - height: 13.w, - ), - ), - PositionedDirectional( - end: 34.w, - bottom: 13.w, - child: CountdownTimer( - expiryDate: - DateTime.fromMillisecondsSinceEpoch( - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.firstRechargeEndTime ?? - 0, - ), - color: Colors.white, - fontSize: 12.w, - ), - ), - ], - ), - onTap: () { - SCDialogUtils.showFirstRechargeDialog( - navigatorKey.currentState!.context, - ); - }, - ) - : Container()), - ], - ), _buildGiftHead(), + if (showFirstRechargeEntry) ...[ + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: _buildFirstRechargeGiftEntry(), + ), + SizedBox(height: 8.w), + ], if (isCpTab) ...[ Padding( padding: EdgeInsets.symmetric(horizontal: 10.w), @@ -1707,6 +1661,14 @@ class _GiftPageState extends State with TickerProviderStateMixin { if (request.isBackpackGiftRequest) { unawaited(_refreshGiftBackpack()); } + if (!request.isBackpackGiftRequest && + _isInsufficientMoneyError(e, errorMessage)) { + RoomGiftComboSendController().hide(); + RoomRechargeBottomSheet.show( + navigatorKey.currentState?.context ?? context, + ); + return; + } SCTts.show(errorMessage); } } @@ -1715,21 +1677,19 @@ class _GiftPageState extends State with TickerProviderStateMixin { _GiftSendRequest request, SocialChatUserProfileManager? profileManager, ) { - if (request.gift.giftTab != SCGiftType.CP.name && - request.gift.giftTab != "CP") { + if (!_isCpGift(request.gift)) { return; } unawaited(profileManager?.refreshLoadedCpProfiles()); } void _sendCpInviteMessageAfterGift(_GiftSendRequest request) { - if (request.gift.giftTab != SCGiftType.CP.name && - request.gift.giftTab != "CP") { + if (!_isCpGift(request.gift)) { return; } if (request.acceptUserIds.length != 1 || request.acceptUsers.length != 1) { _giftFxLog( - 'skip CP invite C2C reason=invalid_accept_count ' + 'skip relation invite C2C reason=invalid_accept_count ' 'acceptUserIds=${request.acceptUserIds.join(",")}', ); return; @@ -1740,15 +1700,16 @@ class _GiftPageState extends State with TickerProviderStateMixin { Future _sendCpInviteMessageAfterGiftAsync( _GiftSendRequest request, ) async { - const relationType = "CP"; + final relationType = _cpRelationTypeForGift(request.gift); final receiverProfile = request.acceptUsers.first.user; final receiverId = (receiverProfile?.id ?? request.acceptUserIds.first).trim(); final currentProfile = AccountStorage().getCurrentUser()?.userProfile; if (receiverId.isEmpty || currentProfile == null) { _giftFxLog( - 'skip CP invite C2C reason=missing_user ' - 'receiverId=$receiverId hasCurrent=${currentProfile != null}', + 'skip relation invite C2C reason=missing_user ' + 'receiverId=$receiverId relationType=$relationType ' + 'hasCurrent=${currentProfile != null}', ); return; } @@ -1764,7 +1725,8 @@ class _GiftPageState extends State with TickerProviderStateMixin { ); if ((applyId ?? "").trim().isEmpty) { _giftFxLog( - 'skip CP invite C2C reason=no_apply_id receiverId=$receiverId', + 'skip relation invite C2C reason=no_apply_id ' + 'receiverId=$receiverId relationType=$relationType', ); return; } @@ -1778,6 +1740,8 @@ class _GiftPageState extends State with TickerProviderStateMixin { account: currentProfile.account ?? currentProfile.id, actualAccount: currentProfile.id, userAvatar: currentProfile.userAvatar ?? "", + userHeaddress: currentProfile.getHeaddress()?.sourceUrl, + userHeaddressCover: currentProfile.getHeaddress()?.cover, userNickname: currentProfile.userNickname ?? "", relationType: relationType, dismissEndTime: expireAtSeconds, @@ -1785,8 +1749,9 @@ class _GiftPageState extends State with TickerProviderStateMixin { giftCount: request.quantity, ); _giftFxLog( - 'send CP invite C2C applyId=$applyId ' - 'receiverId=$receiverId giftId=${request.gift.id}', + 'send relation invite C2C applyId=$applyId ' + 'receiverId=$receiverId relationType=$relationType ' + 'giftId=${request.gift.id}', ); await rtmProvider.sendCpInviteBuildMessage( applyId: applyId!, @@ -1796,7 +1761,8 @@ class _GiftPageState extends State with TickerProviderStateMixin { ); } catch (error) { _giftFxLog( - 'send CP invite C2C failed receiverId=$receiverId error=$error', + 'send relation invite C2C failed ' + 'receiverId=$receiverId relationType=$relationType error=$error', ); } } @@ -1976,6 +1942,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { ); SCGiftVapSvgaManager().play(gift.giftSourceUrl!); } else if (SCGlobalConfig.isLowPerformanceDevice && + SCGlobalConfig.isGiftSpecialEffects && (rtcProvider?.shouldShowRoomVisualEffects ?? false)) { _enqueueLowPerformanceGiftCompensation( gift: gift, @@ -2102,6 +2069,33 @@ class _GiftPageState extends State with TickerProviderStateMixin { ); } + Widget _buildFirstRechargeGiftEntry() { + return Semantics( + button: true, + label: "First recharge offer", + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openFirstRechargeDialog, + child: SizedBox( + width: 355.w, + height: 46.w, + child: Image.asset(_firstRechargeGiftEntryCard, fit: BoxFit.fill), + ), + ), + ); + } + + void _openFirstRechargeDialog() { + SmartDialog.dismiss(tag: "showGiftControl"); + Future.delayed(const Duration(milliseconds: 80), () { + final navigator = navigatorKey.currentState; + if (navigator == null || !navigator.mounted) { + return; + } + SCDialogUtils.showFirstRechargeDialog(navigator.context); + }); + } + void _openCpRightsGuide() { SmartDialog.dismiss(tag: "showGiftControl"); Future.delayed(const Duration(milliseconds: 80), () { diff --git a/lib/modules/gift/gift_tab_page.dart b/lib/modules/gift/gift_tab_page.dart index b0efb3c..4a588d9 100644 --- a/lib/modules/gift/gift_tab_page.dart +++ b/lib/modules/gift/gift_tab_page.dart @@ -13,6 +13,7 @@ import 'package:provider/provider.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/app/config/business_logic_strategy.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; enum _GiftGridPageStatus { idle, loading, ready } @@ -447,73 +448,84 @@ class _GiftTabPageState extends State ) : GestureDetector( behavior: HitTestBehavior.opaque, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.w), - border: Border.all( - color: - checkedIndex == ref.giftByTab[widget.type]!.indexOf(gift) - ? SocialChatTheme.primaryLight - : Colors.transparent, - width: 1.w, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - vertical: _kGiftCardVerticalPadding.w, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - netImage( - url: gift.giftPhoto ?? "", - fit: BoxFit.cover, - width: 48.w, - height: 48.w, - loadingWidget: _buildGiftCoverLoading(), - errorWidget: _buildGiftCoverNoData(), + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.w), + border: Border.all( + color: + checkedIndex == + ref.giftByTab[widget.type]!.indexOf(gift) + ? SocialChatTheme.primaryLight + : Colors.transparent, + width: 1.w, ), - SizedBox(height: _kGiftIconPriceSpacing.w), - SizedBox( - height: _kGiftPriceRowHeight.w, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: - _isBackpackTab - ? [ - text( - "x${_formatBackpackQuantity(gift)}", - fontSize: 10, - lineHeight: 1, - fontWeight: FontWeight.w600, - textColor: - widget.isDark - ? SocialChatTheme.primaryLight - : SocialChatTheme.primaryColor, - ), - ] - : [ - Image.asset( - _strategy.getGiftPageGoldCoinIcon(), - width: 14.w, - height: 14.w, - ), - SizedBox(width: 3.w), - text( - "${gift.giftCandy}", - fontSize: 10, - lineHeight: 1, - textColor: - widget.isDark - ? Colors.white - : Colors.black, - ), - ], - ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + vertical: _kGiftCardVerticalPadding.w, ), - ], + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + netImage( + url: gift.giftPhoto ?? "", + fit: BoxFit.cover, + width: 48.w, + height: 48.w, + loadingWidget: _buildGiftCoverLoading(), + errorWidget: _buildGiftCoverNoData(), + ), + SizedBox(height: _kGiftIconPriceSpacing.w), + SizedBox( + height: _kGiftPriceRowHeight.w, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: + _isBackpackTab + ? [ + text( + "x${_formatBackpackQuantity(gift)}", + fontSize: 10, + lineHeight: 1, + fontWeight: FontWeight.w600, + textColor: + widget.isDark + ? SocialChatTheme.primaryLight + : SocialChatTheme.primaryColor, + ), + ] + : [ + Image.asset( + _strategy.getGiftPageGoldCoinIcon(), + width: 14.w, + height: 14.w, + ), + SizedBox(width: 3.w), + text( + "${gift.giftCandy}", + fontSize: 10, + lineHeight: 1, + textColor: + widget.isDark + ? Colors.white + : Colors.black, + ), + ], + ), + ), + ], + ), + ), ), - ), + if (_shouldShowCpRelationBadge(gift)) + PositionedDirectional( + top: 0, + end: 0, + child: _buildCpRelationBadge(gift), + ), + ], ), onTap: () { setState(() { @@ -524,6 +536,68 @@ class _GiftTabPageState extends State ); } + bool _shouldShowCpRelationBadge(SocialChatGiftRes gift) => + scIsCpGiftTab(widget.type) || scIsCpGift(gift); + + Widget _buildCpRelationBadge(SocialChatGiftRes gift) { + final spec = _cpRelationBadgeSpec(scCpRelationTypeForGift(gift)); + return Container( + width: spec.width.w, + height: 14.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: spec.backgroundColor, + borderRadius: BorderRadius.only( + topRight: Radius.circular(7.w), + bottomLeft: Radius.circular(7.w), + ), + ), + child: Text( + spec.label, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.visible, + style: TextStyle( + color: spec.color, + fontFamily: spec.fontFamily, + fontSize: 10.sp, + fontWeight: FontWeight.w400, + height: 1, + letterSpacing: 0, + ), + ), + ); + } + + _CpRelationBadgeSpec _cpRelationBadgeSpec(String relationType) { + switch (relationType) { + case "BROTHER": + return const _CpRelationBadgeSpec( + label: "brother", + width: 44, + color: Color(0xFF024591), + backgroundColor: Color(0xFFCEE9FF), + fontFamily: "Source Han Sans SC", + ); + case "SISTERS": + return const _CpRelationBadgeSpec( + label: "sister", + width: 39, + color: Color(0xFF880291), + backgroundColor: Color(0xFFEECEFF), + fontFamily: "Source Han Sans SC", + ); + default: + return const _CpRelationBadgeSpec( + label: "cp", + width: 26, + color: Color(0xFFFF59B7), + backgroundColor: Color(0xFFFFCEEA), + fontFamily: "Inter", + ); + } + } + bool get _isBackpackTab => widget.type == SCAppGeneralManager.backpackGiftTab; String _formatBackpackQuantity(SocialChatGiftRes gift) { @@ -557,3 +631,19 @@ class _GiftTabPageState extends State return Row(mainAxisAlignment: MainAxisAlignment.center, children: list); } } + +class _CpRelationBadgeSpec { + const _CpRelationBadgeSpec({ + required this.label, + required this.width, + required this.color, + required this.backgroundColor, + required this.fontFamily, + }); + + final String label; + final double width; + final Color color; + final Color backgroundColor; + final String fontFamily; +} diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index c1e10a4..81319ba 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -7,7 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; -import 'package:yumi/main.dart' show routeObserver; +import 'package:yumi/main.dart' show modalRouteObserver; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/ui_kit/widgets/room/room_bottom_widget.dart'; @@ -24,6 +24,7 @@ import 'package:yumi/shared/tools/sc_lucky_gift_win_sound_player.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart'; import 'package:yumi/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart'; @@ -78,7 +79,8 @@ class _VoiceRoomPageState extends State int _shownRoomStartupFailureToken = 0; RtcProvider? _rtcProvider; bool _roomProviderRebuildScheduled = false; - PageRoute? _routeObserverRoute; + ModalRoute? _routeObserverRoute; + SCRoomTopLayerGuardLease? _coveredRouteGuardLease; bool _roomRouteVisible = false; @override @@ -89,6 +91,9 @@ class _VoiceRoomPageState extends State context, listen: false, ).setVoiceRoomRouteVisible(true); + SCRoomTopLayerGuard().activeListenable.addListener( + _handleRoomTopLayerGuardChanged, + ); _enableRoomVisualEffects(); _tabController.addListener(_handleTabChange); _subscription = eventBus.on().listen(( @@ -119,21 +124,29 @@ class _VoiceRoomPageState extends State context.read().attachRtcProvider(rtcProvider); } final route = ModalRoute.of(context); - if (route is PageRoute && _routeObserverRoute != route) { + if (route != null && _routeObserverRoute != route) { if (_routeObserverRoute != null) { - routeObserver.unsubscribe(this); + modalRouteObserver.unsubscribe(this); } _routeObserverRoute = route; - routeObserver.subscribe(this, route); - _setRoomRouteVisible(route.isCurrent); + modalRouteObserver.subscribe(this, route); + if (route.isCurrent) { + _handleRoomRouteVisible(); + } else { + _handleRoomRouteCovered(); + } } _ensureRoomVisualEffectsEnabled(); } @override void dispose() { - routeObserver.unsubscribe(this); + SCRoomTopLayerGuard().activeListenable.removeListener( + _handleRoomTopLayerGuardChanged, + ); + modalRouteObserver.unsubscribe(this); _routeObserverRoute = null; + _releaseCoveredRouteGuard(); _setRoomRouteVisible(false); _rtcProvider?.removeListener(_handleRoomProviderChanged); _rtcProvider = null; @@ -147,22 +160,52 @@ class _VoiceRoomPageState extends State @override void didPush() { - _setRoomRouteVisible(true); + _handleRoomRouteVisible(); } @override void didPopNext() { - _setRoomRouteVisible(true); + _handleRoomRouteVisible(); } @override void didPushNext() { - _setRoomRouteVisible(false); + _handleRoomRouteCovered(); } @override void didPop() { + _handleRoomRouteCovered(); + _releaseCoveredRouteGuard(); + } + + void _handleRoomRouteVisible() { + _releaseCoveredRouteGuard(); + _setRoomRouteVisible(true); + } + + void _handleRoomRouteCovered() { _setRoomRouteVisible(false); + _acquireCoveredRouteGuard(); + _clearTransientRoomVisualEffects(reason: 'voice_room_route_covered'); + } + + void _acquireCoveredRouteGuard() { + _coveredRouteGuardLease ??= SCRoomTopLayerGuard().acquire( + reason: 'voice_room_route_covered', + ); + } + + void _releaseCoveredRouteGuard() { + _coveredRouteGuardLease?.release(); + _coveredRouteGuardLease = null; + } + + void _handleRoomTopLayerGuardChanged() { + if (!SCRoomTopLayerGuard().isActive || !mounted) { + return; + } + _clearTransientRoomVisualEffects(reason: 'room_top_layer_guard'); } void _setRoomRouteVisible(bool visible) { @@ -217,6 +260,29 @@ class _VoiceRoomPageState extends State _enableRoomVisualEffects(); } + void _clearTransientRoomVisualEffects({required String reason}) { + if (!mounted) { + return; + } + final rtcProvider = Provider.of(context, listen: false); + final currentRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; + + RoomEntranceHelper.clearQueue(); + Provider.of(context, listen: false).clearAllGiftData(); + Provider.of( + context, + listen: false, + ).clearActiveAnimations(); + _clearLuckyGiftComboSessions(); + _clearGiftFlightBatchDedupeTimers(); + _clearGiftVisualBatchDedupeTimers(); + _giftSeatFlightController.clear(); + OverlayManager().removeRoom(roomId: currentRoomId); + SCRoomEffectScheduler().clearDeferredTasks(reason: reason); + SCGiftVapSvgaManager().stopPlayback(); + } + void _suspendRoomVisualEffects() { final rtcProvider = Provider.of(context, listen: false); final currentRoomId = diff --git a/lib/modules/room_game/views/room_game_list_sheet.dart b/lib/modules/room_game/views/room_game_list_sheet.dart index f44eaef..4961a61 100644 --- a/lib/modules/room_game/views/room_game_list_sheet.dart +++ b/lib/modules/room_game/views/room_game_list_sheet.dart @@ -12,6 +12,7 @@ import 'package:yumi/modules/room_game/views/baishun_game_page.dart'; import 'package:yumi/modules/room_game/views/leader_game_page.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; @@ -126,6 +127,7 @@ class _RoomGameListSheetState extends State { _launchingGameId = game.gameId; }); + SCRoomTopLayerGuardLease? gameDialogGuard; try { _log( 'launch_request roomId=$_roomId gameId=${game.gameId} ' @@ -147,23 +149,38 @@ class _RoomGameListSheetState extends State { return; } + gameDialogGuard = SCRoomTopLayerGuard().acquire(reason: 'room_game_page'); Navigator.of(context).pop(); await Future.delayed(const Duration(milliseconds: 180)); if (!widget.roomContext.mounted) { + gameDialogGuard.release(); + gameDialogGuard = null; return; } final gamePage = _buildGamePage(game, launchModel); if (gamePage == null) { + gameDialogGuard.release(); + gameDialogGuard = null; SCTts.show('This game provider is not wired yet'); return; } - showBottomInBottomDialog( - widget.roomContext, - gamePage, - barrierColor: Colors.black54, - barrierDismissible: true, - ); + final activeGameDialogGuard = gameDialogGuard; + gameDialogGuard = null; + try { + unawaited( + showBottomInBottomDialog( + widget.roomContext, + gamePage, + barrierColor: Colors.black54, + barrierDismissible: true, + ).whenComplete(activeGameDialogGuard.release), + ); + } catch (_) { + activeGameDialogGuard.release(); + rethrow; + } } catch (error) { + gameDialogGuard?.release(); _log('launch_error error=${_clip(error.toString(), 400)}'); SCTts.show('Launch failed'); } finally { diff --git a/lib/modules/user/my_items/chatbox/bags_chatbox_page.dart b/lib/modules/user/my_items/chatbox/bags_chatbox_page.dart index c8296f0..8a83641 100644 --- a/lib/modules/user/my_items/chatbox/bags_chatbox_page.dart +++ b/lib/modules/user/my_items/chatbox/bags_chatbox_page.dart @@ -261,17 +261,20 @@ class _BagsChatboxPageState ], ), ), - onTap: () { + onTap: () async { selecteChatbox = res; setState(() {}); + if (myChatbox?.id != res.propsResources?.id) { + final success = await _use(res, false); + if (!success || !mounted) { + return; + } + } showStoreBagFullScreenResourcePreview( context, resource: res.propsResources, fit: BoxFit.contain, ); - if (myChatbox?.id != res.propsResources?.id) { - _use(res, false); - } }, ); } @@ -317,31 +320,36 @@ class _BagsChatboxPageState ); } - void _use(BagsListRes res, bool unload) { + Future _use(BagsListRes res, bool unload) async { SCLoadingManager.show(context: context); - SCStoreRepositoryImp() - .switchPropsUse( - SCPropsType.CHAT_BUBBLE.name, - res.propsResources?.id ?? "", - unload, - ) - .then((value) async { - setState(() {}); - if (!unload) { - myChatbox = res.propsResources; - SCTts.show(SCAppLocalizations.of(context)!.successfulWear); - } else { - myChatbox = null; - SCTts.show(SCAppLocalizations.of(context)!.successfullyUnloaded); - } - Provider.of( - context, - listen: false, - ).fetchUserProfileData(loadGuardCount: false); - SCLoadingManager.hide(); - }) - .catchError((e) { - SCLoadingManager.hide(); - }); + final localizations = SCAppLocalizations.of(context)!; + final profileManager = Provider.of( + context, + listen: false, + ); + try { + await SCStoreRepositoryImp().switchPropsUse( + SCPropsType.CHAT_BUBBLE.name, + res.propsResources?.id ?? "", + unload, + ); + if (!mounted) { + return false; + } + if (!unload) { + myChatbox = res.propsResources; + SCTts.show(localizations.successfulWear); + } else { + myChatbox = null; + SCTts.show(localizations.successfullyUnloaded); + } + setState(() {}); + profileManager.fetchUserProfileData(loadGuardCount: false); + return true; + } catch (e) { + return false; + } finally { + SCLoadingManager.hide(); + } } } diff --git a/lib/modules/user/my_items/data_card/bags_data_card_page.dart b/lib/modules/user/my_items/data_card/bags_data_card_page.dart index 0a87ff0..b2c10a6 100644 --- a/lib/modules/user/my_items/data_card/bags_data_card_page.dart +++ b/lib/modules/user/my_items/data_card/bags_data_card_page.dart @@ -273,23 +273,32 @@ class BagsDataCardPageState ], ), ), - onTap: () { + onTap: () async { selectedDataCard = res; setState(() {}); + final expired = + (res.expireTime ?? 0) < DateTime.now().millisecondsSinceEpoch; + if (expired) { + showStoreBagFullScreenResourcePreview( + context, + resource: res.propsResources, + previewKind: SCStoreItemPreviewKind.dataCard, + fit: BoxFit.contain, + ); + return; + } + if (myDataCard?.id != res.propsResources?.id) { + final success = await _use(res, false); + if (!success || !mounted) { + return; + } + } showStoreBagFullScreenResourcePreview( context, resource: res.propsResources, previewKind: SCStoreItemPreviewKind.dataCard, fit: BoxFit.contain, ); - final expired = - (res.expireTime ?? 0) < DateTime.now().millisecondsSinceEpoch; - if (expired) { - return; - } - if (myDataCard?.id != res.propsResources?.id) { - _use(res, false); - } }, ); } @@ -332,37 +341,36 @@ class BagsDataCardPageState ); } - void _use(BagsListRes res, bool unload) { + Future _use(BagsListRes res, bool unload) async { SCLoadingManager.show(context: context); final profileManager = Provider.of( context, listen: false, ); final localizations = SCAppLocalizations.of(context)!; - SCStoreRepositoryImp() - .switchPropsUse( - SCPropsType.DATA_CARD.name, - res.propsResources?.id ?? "", - unload, - ) - .then((value) async { - if (!mounted) { - SCLoadingManager.hide(); - return; - } - setState(() {}); - if (!unload) { - myDataCard = res.propsResources; - SCTts.show(localizations.successfulWear); - } else { - myDataCard = null; - SCTts.show(localizations.successfullyUnloaded); - } - profileManager.fetchUserProfileData(loadGuardCount: false); - SCLoadingManager.hide(); - }) - .catchError((e) { - SCLoadingManager.hide(); - }); + try { + await SCStoreRepositoryImp().switchPropsUse( + SCPropsType.DATA_CARD.name, + res.propsResources?.id ?? "", + unload, + ); + if (!mounted) { + return false; + } + if (!unload) { + myDataCard = res.propsResources; + SCTts.show(localizations.successfulWear); + } else { + myDataCard = null; + SCTts.show(localizations.successfullyUnloaded); + } + setState(() {}); + profileManager.fetchUserProfileData(loadGuardCount: false); + return true; + } catch (_) { + return false; + } finally { + SCLoadingManager.hide(); + } } } diff --git a/lib/modules/user/my_items/headdress/bags_headdress_page.dart b/lib/modules/user/my_items/headdress/bags_headdress_page.dart index 1b40dc8..f0df158 100644 --- a/lib/modules/user/my_items/headdress/bags_headdress_page.dart +++ b/lib/modules/user/my_items/headdress/bags_headdress_page.dart @@ -261,18 +261,21 @@ class _BagsHeaddressPageState ], ), ), - onTap: () { + onTap: () async { selecteHeaddress = res; setState(() {}); + if (myHeaddress?.id != res.propsResources?.id) { + final success = await _use(res, false); + if (!success || !mounted) { + return; + } + } showStoreBagFullScreenResourcePreview( context, resource: res.propsResources, previewKind: SCStoreItemPreviewKind.avatarFrame, fit: BoxFit.contain, ); - if (myHeaddress?.id != res.propsResources?.id) { - _use(res, false); - } }, ); } @@ -318,31 +321,36 @@ class _BagsHeaddressPageState ); } - void _use(BagsListRes res, bool unload) { + Future _use(BagsListRes res, bool unload) async { SCLoadingManager.show(context: context); - SCStoreRepositoryImp() - .switchPropsUse( - SCPropsType.AVATAR_FRAME.name, - res.propsResources?.id ?? "", - unload, - ) - .then((value) async { - setState(() {}); - if (!unload) { - myHeaddress = res.propsResources; - SCTts.show(SCAppLocalizations.of(context)!.successfulWear); - } else { - myHeaddress = null; - SCTts.show(SCAppLocalizations.of(context)!.successfullyUnloaded); - } - Provider.of( - context, - listen: false, - ).fetchUserProfileData(loadGuardCount: false); - SCLoadingManager.hide(); - }) - .catchError((e) { - SCLoadingManager.hide(); - }); + final localizations = SCAppLocalizations.of(context)!; + final profileManager = Provider.of( + context, + listen: false, + ); + try { + await SCStoreRepositoryImp().switchPropsUse( + SCPropsType.AVATAR_FRAME.name, + res.propsResources?.id ?? "", + unload, + ); + if (!mounted) { + return false; + } + if (!unload) { + myHeaddress = res.propsResources; + SCTts.show(localizations.successfulWear); + } else { + myHeaddress = null; + SCTts.show(localizations.successfullyUnloaded); + } + setState(() {}); + profileManager.fetchUserProfileData(loadGuardCount: false); + return true; + } catch (e) { + return false; + } finally { + SCLoadingManager.hide(); + } } } diff --git a/lib/modules/user/my_items/mountains/bags_mountains_page.dart b/lib/modules/user/my_items/mountains/bags_mountains_page.dart index d993ecf..e442995 100644 --- a/lib/modules/user/my_items/mountains/bags_mountains_page.dart +++ b/lib/modules/user/my_items/mountains/bags_mountains_page.dart @@ -1,7 +1,5 @@ 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/bag/props_bag_mountains_detail_dialog.dart'; import 'package:provider/provider.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; @@ -246,17 +244,20 @@ class _BagsMountainsPageState ], ), ), - onTap: () { + onTap: () async { selecteMountains = res; setState(() {}); + if (myMountains?.id != res.propsResources?.id) { + final success = await _use(res, false); + if (!success || !mounted) { + return; + } + } showStoreBagFullScreenResourcePreview( context, resource: res.propsResources, fit: BoxFit.contain, ); - if (myMountains?.id != res.propsResources?.id) { - _use(res, false); - } }, ); } @@ -287,46 +288,36 @@ class _BagsMountainsPageState } } - void _showDetail(BagsListRes res) { - SmartDialog.show( - tag: "showPropsDetail", - alignment: Alignment.center, - animationType: SmartAnimationType.fade, - builder: (_) { - return PropsBagMountainsDetailDialog(res); - }, - onDismiss: () { - myMountains = AccountStorage().getMountains(); - setState(() {}); - }, - ); - } - - void _use(BagsListRes res, bool unload) { + Future _use(BagsListRes res, bool unload) async { SCLoadingManager.show(context: context); - SCStoreRepositoryImp() - .switchPropsUse( - SCPropsType.RIDE.name, - res.propsResources?.id ?? "", - unload, - ) - .then((value) async { - setState(() {}); - if (!unload) { - myMountains = res.propsResources; - SCTts.show(SCAppLocalizations.of(context)!.successfulWear); - } else { - myMountains = null; - SCTts.show(SCAppLocalizations.of(context)!.successfullyUnloaded); - } - Provider.of( - context, - listen: false, - ).fetchUserProfileData(loadGuardCount: false); - SCLoadingManager.hide(); - }) - .catchError((e) { - SCLoadingManager.hide(); - }); + final localizations = SCAppLocalizations.of(context)!; + final profileManager = Provider.of( + context, + listen: false, + ); + try { + await SCStoreRepositoryImp().switchPropsUse( + SCPropsType.RIDE.name, + res.propsResources?.id ?? "", + unload, + ); + if (!mounted) { + return false; + } + if (!unload) { + myMountains = res.propsResources; + SCTts.show(localizations.successfulWear); + } else { + myMountains = null; + SCTts.show(localizations.successfullyUnloaded); + } + setState(() {}); + profileManager.fetchUserProfileData(loadGuardCount: false); + return true; + } catch (e) { + return false; + } finally { + SCLoadingManager.hide(); + } } } diff --git a/lib/modules/user/my_items/theme/bags_theme_page.dart b/lib/modules/user/my_items/theme/bags_theme_page.dart index e649a3d..b0a46cd 100644 --- a/lib/modules/user/my_items/theme/bags_theme_page.dart +++ b/lib/modules/user/my_items/theme/bags_theme_page.dart @@ -9,7 +9,6 @@ import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import 'package:yumi/ui_kit/components/sc_page_list.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart'; -import 'package:yumi/ui_kit/widgets/bag/props_bag_theme_detail_dialog.dart'; import 'package:yumi/ui_kit/widgets/countdown_timer.dart'; import 'package:yumi/modules/store/store_route.dart'; import 'package:provider/provider.dart'; @@ -231,18 +230,21 @@ class _BagsThemePageState ], ), ), - onTap: () { + onTap: () async { selecteTheme = res; setState(() {}); + if (!(res.useTheme ?? false)) { + final success = await _use(res, false); + if (!success || !mounted) { + return; + } + } showStoreBagFullScreenResourcePreview( context, sourceUrl: res.themeBack, coverUrl: res.themeBack, fit: BoxFit.contain, ); - if (!(res.useTheme ?? false)) { - _use(res, false); - } }, ); } @@ -270,62 +272,46 @@ class _BagsThemePageState } } - void _use(SCRoomThemeListRes res, bool unload) { + Future _use(SCRoomThemeListRes res, bool unload) async { SCLoadingManager.show(); - SCStoreRepositoryImp() - .themeSwitchUse(res.id ?? "", unload) - .then((value) async { - SCLoadingManager.hide(); - SmartDialog.dismiss(tag: "showPropsDetail"); - if (!unload) { - SCTts.show(SCAppLocalizations.of(context)!.successfulWear); - Provider.of( - context!, - listen: false, - ).updateRoomBG(res); - } else { - Provider.of( - context!, - listen: false, - ).updateRoomBG(SCRoomThemeListRes()); - selecteTheme = null; - SCTts.show(SCAppLocalizations.of(context)!.successfullyUnloaded); - } - loadData(1); - - ///发送一条消息更新远端房间信息 - String? groupId = - Provider.of( - context, - listen: false, - ).currenRoom?.roomProfile?.roomProfile?.roomAccount; - if (groupId != null) { - Msg msg = Msg( - groupId: groupId, - msg: unload ? "" : jsonEncode(res.toJson()), - type: SCRoomMsgType.roomBGUpdate, - ); - Provider.of( - context!, - listen: false, - ).dispatchMessage(msg, addLocal: false); - } - }) - .catchError((e) { - SCLoadingManager.hide(); - }); - } - - void _showDetail(SCRoomThemeListRes res) { - SmartDialog.show( - tag: "showPropsDetail", - alignment: Alignment.center, - animationType: SmartAnimationType.fade, - builder: (_) { - return PropsBagThemeDetailDialog(res, () { - loadData(1); - }); - }, + final localizations = SCAppLocalizations.of(context)!; + final rtcManager = Provider.of( + context, + listen: false, ); + final rtmProvider = Provider.of(context, listen: false); + try { + await SCStoreRepositoryImp().themeSwitchUse(res.id ?? "", unload); + if (!mounted) { + return false; + } + SmartDialog.dismiss(tag: "showPropsDetail"); + if (!unload) { + SCTts.show(localizations.successfulWear); + rtcManager.updateRoomBG(res); + } else { + rtcManager.updateRoomBG(SCRoomThemeListRes()); + selecteTheme = null; + SCTts.show(localizations.successfullyUnloaded); + } + loadData(1); + + ///发送一条消息更新远端房间信息 + String? groupId = + rtcManager.currenRoom?.roomProfile?.roomProfile?.roomAccount; + if (groupId != null) { + Msg msg = Msg( + groupId: groupId, + msg: unload ? "" : jsonEncode(res.toJson()), + type: SCRoomMsgType.roomBGUpdate, + ); + rtmProvider.dispatchMessage(msg, addLocal: false); + } + return true; + } catch (e) { + return false; + } finally { + SCLoadingManager.hide(); + } } } diff --git a/lib/modules/webview/webview_page.dart b/lib/modules/webview/webview_page.dart index 93a209d..e634901 100644 --- a/lib/modules/webview/webview_page.dart +++ b/lib/modules/webview/webview_page.dart @@ -14,6 +14,7 @@ import 'package:yumi/app/constants/sc_screen.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_deviceId_utils.dart'; import 'package:yumi/shared/tools/sc_h5_url_utils.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_url_launcher_utils.dart'; import 'package:yumi/main.dart'; @@ -40,6 +41,7 @@ class WebViewPage extends StatefulWidget { class _WebViewPageState extends State { late WebViewController _controller; + late final SCRoomTopLayerGuardLease _topLayerGuardLease; String _title = ""; double _progress = 0.0; bool _isLoading = true; @@ -48,6 +50,7 @@ class _WebViewPageState extends State { @override void initState() { super.initState(); + _topLayerGuardLease = SCRoomTopLayerGuard().acquire(reason: 'webview_page'); urlLink = SCH5UrlUtils.appendAppParams(widget.url); if (kDebugMode) { debugPrint('[WebViewPage] load url=${_maskSensitiveUrl(urlLink)}'); @@ -177,6 +180,7 @@ class _WebViewPageState extends State { @override void dispose() { _controller.loadRequest(Uri.parse('about:blank')); + _topLayerGuardLease.release(); super.dispose(); } diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 5143cb9..3abc932 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/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/tools/sc_cp_relation_notice_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'; @@ -490,7 +491,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { int? get previewRoomSeatCount => _previewRoomSeatCount; bool get shouldShowRoomVisualEffects => - currenRoom != null && _roomVisualEffectsEnabled; + currenRoom != null && + _roomVisualEffectsEnabled && + _voiceRoomRouteVisible && + !SCRoomTopLayerGuard().isActive; void _startVoiceRoomForegroundService() { final roomProfile = currenRoom?.roomProfile?.roomProfile; @@ -530,7 +534,18 @@ class RealTimeCommunicationManager extends ChangeNotifier { notifyListeners(); } + void notifyRoomVisualEffectPreferenceChanged() { + if (!SCGlobalConfig.isEntryVehicleAnimation) { + _roomEntryEffectTimer?.cancel(); + _roomEntryEffectTimer = null; + } + notifyListeners(); + } + void setVoiceRoomRouteVisible(bool visible) { + if (_voiceRoomRouteVisible == visible) { + return; + } _voiceRoomRouteVisible = visible; if (visible) { final roomId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); @@ -538,6 +553,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _scheduleRoomRocketEntryReadyFallback(roomId); } } + notifyListeners(); } void _setRoomStartupLoading() { @@ -3178,6 +3194,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { }); } else if (sourceUrl.isNotEmpty && SCGlobalConfig.isLowPerformanceDevice && + SCGlobalConfig.isEntryVehicleAnimation && shouldShowRoomVisualEffects && _isCurrentRoomEntrySession(entryRequestSerial, roomId)) { _roomEntryEffectTimer?.cancel(); @@ -3236,6 +3253,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { .map((item) => item.id) .where((item) => item.isNotEmpty) .toList(growable: false); + if (SCRoomTopLayerGuard().isActive) { + SCRoomTopLayerGuard().runWhenInactive(() { + _loadRoomRocketRewardPopups(roomId); + }); + return; + } SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); SmartDialog.show( tag: _roomRocketRewardDialogTag, @@ -3245,21 +3268,25 @@ class RealTimeCommunicationManager extends ChangeNotifier { maskColor: Colors.transparent, clickMaskDismiss: false, builder: - (_) => RoomRocketRewardDialogLoader( - roomId: roomId, - initialPopupRecords: res.records, - userProfileResolver: roomRocketRewardUserProfileForRecord, - onClose: () { - SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); - scheduleRoomRocketPostLaunchStatusRefresh(roomId: roomId); - if (recordIds.isNotEmpty) { - unawaited( - SCChatRoomRepository().ackRoomRocketRewardPopups( - recordIds, - ), - ); - } - }, + (_) => SCRoomTopLayerSuppressor( + reason: 'room_rocket_reward_dialog', + keepDialogTags: const {_roomRocketRewardDialogTag}, + child: RoomRocketRewardDialogLoader( + roomId: roomId, + initialPopupRecords: res.records, + userProfileResolver: roomRocketRewardUserProfileForRecord, + onClose: () { + SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); + scheduleRoomRocketPostLaunchStatusRefresh(roomId: roomId); + if (recordIds.isNotEmpty) { + unawaited( + SCChatRoomRepository().ackRoomRocketRewardPopups( + recordIds, + ), + ); + } + }, + ), ), ); }) @@ -3417,7 +3444,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomRocketLaunchAnimationTimer?.cancel(); _releaseRoomRocketLaunchTopAvatarNotifier(); - if (shouldShowRoomVisualEffects && isVoiceRoomRouteVisible) { + if (shouldShowRoomVisualEffects && + isVoiceRoomRouteVisible && + !SCRoomTopLayerGuard().isActive) { final animationUrl = _firstNonBlankText([ launch.rocketAnimationUrl, @@ -3458,6 +3487,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { required String animationUrl, required RoomRocketLaunchBroadcastMessage launch, }) { + if (SCRoomTopLayerGuard().isActive) { + return; + } if (RoomRocketPagEffectOverlay.isPag(animationUrl)) { final topAvatarNotifier = ValueNotifier( _roomRocketTop1AvatarUrl(launch.safeLevel), diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 5c88e82..15357a2 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -12,6 +12,7 @@ import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/tools/sc_message_utils.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; @@ -896,6 +897,8 @@ class RealTimeMessagingManager extends ChangeNotifier { inviter: RoomCpInviteDialogUser( name: currentName, avatarUrl: currentProfile.userAvatar ?? "", + headdress: currentProfile.getHeaddress()?.sourceUrl ?? "", + headdressCover: currentProfile.getHeaddress()?.cover ?? "", ), receiver: receiver, style: RoomCpInviteDialogStyle.waiting, @@ -920,10 +923,14 @@ class RealTimeMessagingManager extends ChangeNotifier { inviter: RoomCpInviteDialogUser( name: inviterName, avatarUrl: invite.userAvatar ?? "", + headdress: invite.userHeaddress ?? "", + headdressCover: invite.userHeaddressCover ?? "", ), receiver: RoomCpInviteDialogUser( name: currentName, avatarUrl: currentProfile.userAvatar ?? "", + headdress: currentProfile.getHeaddress()?.sourceUrl ?? "", + headdressCover: currentProfile.getHeaddress()?.cover ?? "", ), style: RoomCpInviteDialogStyle.incoming, countdownSeconds: _cpInviteCountdownSeconds(invite), @@ -1216,12 +1223,16 @@ class RealTimeMessagingManager extends ChangeNotifier { "actualAccount": invite.actualAccount, "userNickname": invite.userNickname, "userAvatar": invite.userAvatar, + "userHeaddress": invite.userHeaddress, + "userHeaddressCover": invite.userHeaddressCover, "acceptUserId": profile?.id, "acceptAccount": profile?.account, "acceptActualAccount": profile?.id, "acceptNickname": acceptName, "acceptUserNickname": profile?.userNickname, "acceptUserAvatar": profile?.userAvatar, + "acceptHeaddress": profile?.getHeaddress()?.sourceUrl, + "acceptHeaddressCover": profile?.getHeaddress()?.cover, "content": jsonEncode({...invite.toJson(), "relationType": relationType}), }; _cpInviteLog( @@ -1316,12 +1327,16 @@ class RealTimeMessagingManager extends ChangeNotifier { "actualAccount": invite.actualAccount, "userNickname": invite.userNickname, "userAvatar": invite.userAvatar, + "userHeaddress": invite.userHeaddress, + "userHeaddressCover": invite.userHeaddressCover, "acceptUserId": profile?.id, "acceptAccount": profile?.account, "acceptActualAccount": profile?.id, "acceptNickname": rejectName, "acceptUserNickname": profile?.userNickname, "acceptUserAvatar": profile?.userAvatar, + "acceptHeaddress": profile?.getHeaddress()?.sourceUrl, + "acceptHeaddressCover": profile?.getHeaddress()?.cover, "content": jsonEncode({...invite.toJson(), "relationType": relationType}), }; _cpInviteLog( @@ -1367,12 +1382,16 @@ class RealTimeMessagingManager extends ChangeNotifier { "actualAccount": invite.actualAccount, "userNickname": invite.userNickname, "userAvatar": invite.userAvatar, + "userHeaddress": invite.userHeaddress, + "userHeaddressCover": invite.userHeaddressCover, "acceptUserId": receiverProfile?.id ?? targetId, "acceptAccount": receiverProfile?.account ?? targetId, "acceptActualAccount": receiverProfile?.id ?? targetId, "acceptNickname": receiverName, "acceptUserNickname": receiverProfile?.userNickname, "acceptUserAvatar": receiverProfile?.userAvatar, + "acceptHeaddress": receiverProfile?.getHeaddress()?.sourceUrl, + "acceptHeaddressCover": receiverProfile?.getHeaddress()?.cover, "content": jsonEncode({...invite.toJson(), "relationType": relationType}), }; _cpInviteLog( @@ -1661,6 +1680,34 @@ class RealTimeMessagingManager extends ChangeNotifier { content["receiverUserAvatar"]?.toString(), ]) ?? "", + headdress: + _firstCpInviteValue([ + data["acceptHeaddress"]?.toString(), + data["acceptAvatarFrame"]?.toString(), + data["acceptAvatarFrameUrl"]?.toString(), + data["receiverHeaddress"]?.toString(), + data["receiverAvatarFrame"]?.toString(), + data["receiverAvatarFrameUrl"]?.toString(), + content["acceptHeaddress"]?.toString(), + content["acceptAvatarFrame"]?.toString(), + content["acceptAvatarFrameUrl"]?.toString(), + content["receiverHeaddress"]?.toString(), + content["receiverAvatarFrame"]?.toString(), + content["receiverAvatarFrameUrl"]?.toString(), + ]) ?? + "", + headdressCover: + _firstCpInviteValue([ + data["acceptHeaddressCover"]?.toString(), + data["acceptAvatarFrameCover"]?.toString(), + data["receiverHeaddressCover"]?.toString(), + data["receiverAvatarFrameCover"]?.toString(), + content["acceptHeaddressCover"]?.toString(), + content["acceptAvatarFrameCover"]?.toString(), + content["receiverHeaddressCover"]?.toString(), + content["receiverAvatarFrameCover"]?.toString(), + ]) ?? + "", ); } @@ -3982,6 +4029,18 @@ class RealTimeMessagingManager extends ChangeNotifier { ); return; } + if (SCRoomTopLayerGuard().isActive) { + SCRoomTopLayerGuard().runWhenInactive(() { + unawaited( + _showRoomRocketRewardPopupDialog( + roomId, + attempt: attempt, + initialRoomRecords: initialRoomRecords, + ), + ); + }); + return; + } try { final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId); if (!_isCurrentVisibleVoiceRoom(roomId)) { @@ -4013,20 +4072,26 @@ class RealTimeMessagingManager extends ChangeNotifier { maskColor: Colors.transparent, clickMaskDismiss: false, builder: - (_) => RoomRocketRewardDialogLoader( - roomId: roomId, - initialPopupRecords: res.records, - initialRoomRecords: initialRoomRecords, - userProfileResolver: userProfileResolver, - onClose: () { - SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); - _scheduleRoomRocketPostLaunchStatusRefresh(roomId); - if (recordIds.isNotEmpty) { - unawaited( - SCChatRoomRepository().ackRoomRocketRewardPopups(recordIds), - ); - } - }, + (_) => SCRoomTopLayerSuppressor( + reason: 'room_rocket_reward_dialog', + keepDialogTags: const {_roomRocketRewardDialogTag}, + child: RoomRocketRewardDialogLoader( + roomId: roomId, + initialPopupRecords: res.records, + initialRoomRecords: initialRoomRecords, + userProfileResolver: userProfileResolver, + onClose: () { + SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); + _scheduleRoomRocketPostLaunchStatusRefresh(roomId); + if (recordIds.isNotEmpty) { + unawaited( + SCChatRoomRepository().ackRoomRocketRewardPopups( + recordIds, + ), + ); + } + }, + ), ), ); } catch (_) { @@ -4989,6 +5054,7 @@ class RealTimeMessagingManager extends ChangeNotifier { type: SCGiftVapSvgaManager.entryEffectType, ); } else if (SCGlobalConfig.isLowPerformanceDevice && + SCGlobalConfig.isEntryVehicleAnimation && shouldShowRoomVisualEffects) { final roomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id ?? ""; @@ -5061,6 +5127,7 @@ class RealTimeMessagingManager extends ChangeNotifier { ); SCGiftVapSvgaManager().play(giftSourceUrl); } else if (SCGlobalConfig.isLowPerformanceDevice && + SCGlobalConfig.isGiftSpecialEffects && rtcProvider.shouldShowRoomVisualEffects) { final roomId = (msg.msg?.trim().isNotEmpty ?? false) diff --git a/lib/services/payment/google_payment_manager.dart b/lib/services/payment/google_payment_manager.dart index bb590c7..d479d71 100644 --- a/lib/services/payment/google_payment_manager.dart +++ b/lib/services/payment/google_payment_manager.dart @@ -24,7 +24,7 @@ class AndroidPaymentProcessor extends ChangeNotifier { // 状态管理变量 bool _isAvailable = false; String _errorMessage = ''; - List _products = []; + final List _products = []; List _purchases = []; ///商品列表 @@ -143,9 +143,6 @@ class AndroidPaymentProcessor extends ChangeNotifier { _productTypeMap[product.produc.id] = 'nonConsumable'; // 默认类型 } } - - // 打印商品信息用于调试 - for (var product in _products) {} } catch (e) { SCTts.show("Failed to retrieve the product: $e"); _errorMessage = '获取商品失败: ${e.toString()}'; @@ -163,11 +160,11 @@ class AndroidPaymentProcessor extends ChangeNotifier { // 发起购买 Future processPurchase() async { ProductDetails? product; - _products.forEach((d) { + for (final d in _products) { if (d.isSelecte) { product = d.produc; } - }); + } if (product == null) { SCTts.show(SCAppLocalizations.of(context)!.pleaseSelectaItem); return; @@ -190,6 +187,76 @@ class AndroidPaymentProcessor extends ChangeNotifier { ); } + Future processFirstRechargePurchase({ + required BuildContext context, + required int rechargeAmountCents, + String productPackage = '', + }) async { + this.context = context; + if (!Platform.isAndroid) { + SCTts.show('Google Play payment is only available on Android'); + return; + } + if (!_isAvailable || _products.isEmpty || productMap.isEmpty) { + await initializePaymentProcessor(context); + } + if (!_isAvailable) { + return; + } + + final product = _findFirstRechargeProduct( + rechargeAmountCents: rechargeAmountCents, + productPackage: productPackage, + ); + if (product == null) { + SCTts.show('No matching Google Play product was found'); + return; + } + + for (final item in _products) { + item.isSelecte = identical(item, product); + } + notifyListeners(); + await _goBuy(product.produc); + } + + SelecteProductConfig? _findFirstRechargeProduct({ + required int rechargeAmountCents, + required String productPackage, + }) { + final targetProduct = productPackage.trim(); + if (targetProduct.isNotEmpty) { + for (final product in _products) { + if (product.produc.id == targetProduct) { + return product; + } + } + } + + if (rechargeAmountCents <= 0) { + return null; + } + + for (final product in _products) { + final config = productMap[product.produc.id]; + if (_matchesAmountCents(config?.unitPrice, rechargeAmountCents)) { + return product; + } + if (_matchesAmountCents(product.produc.rawPrice, rechargeAmountCents)) { + return product; + } + } + return null; + } + + bool _matchesAmountCents(num? value, int targetCents) { + if (value == null) { + return false; + } + final cents = value >= 20 ? value.round() : (value * 100).round(); + return (cents - targetCents).abs() <= 1; + } + // 处理购买结果 void _handlePurchase(List purchases) { _purchases = purchases; @@ -227,22 +294,29 @@ class AndroidPaymentProcessor extends ChangeNotifier { // 新增:处理已拥有商品的情况 Future _handleAlreadyOwnedItem(PurchaseDetails purchase) async { + final profileManager = Provider.of( + context, + listen: false, + ); try { // 如果是消耗型商品,尝试消耗 if (_getProductType(purchase.productID) == 'consumable') { await consumePurchase(purchase); // 消耗后重新获取余额 - Provider.of( - context, - listen: false, - ).balance(); + profileManager.balance(); SCTts.show('outstanding purchases have been reinstated'); } - } catch (e) {} + } catch (e) { + debugPrint('handle already-owned purchase failed: $e'); + } } // 验证支付凭证 Future _verifyPayment(PurchaseDetails purchase) async { + final profileManager = Provider.of( + context, + listen: false, + ); try { // 1. 基本验证 if (purchase.verificationData.serverVerificationData.isEmpty) { @@ -283,14 +357,8 @@ class AndroidPaymentProcessor extends ChangeNotifier { } // 8. 更新用户余额 - Provider.of( - context, - listen: false, - ).fetchUserProfileData(); - Provider.of( - context, - listen: false, - ).balance(); + profileManager.fetchUserProfileData(); + profileManager.balance(); SCTts.show('purchase successful'); } catch (e) { @@ -316,7 +384,9 @@ class AndroidPaymentProcessor extends ChangeNotifier { // 尝试消耗购买 await consumePurchase(purchase); - } catch (e) {} + } catch (e) { + debugPrint('consume purchase failed: $e'); + } } void _handleError(Object error, StackTrace stackTrace) { @@ -396,7 +466,7 @@ class AndroidPaymentProcessor extends ChangeNotifier { } // 购买流程 - void _goBuy(ProductDetails product) async { + Future _goBuy(ProductDetails product) async { try { SCLoadingManager.show(context: context); notifyListeners(); @@ -442,12 +512,14 @@ class AndroidPaymentProcessor extends ChangeNotifier { // 给一点时间处理恢复的购买 await Future.delayed(Duration(milliseconds: 550)); - } catch (e) {} + } catch (e) { + debugPrint('check pending purchases failed: $e'); + } } // 添加调试方法 void logCurrentPurchaseStatus() { - for (var purchase in _purchases) {} + debugPrint('current purchase count: ${_purchases.length}'); } // 释放资源 diff --git a/lib/shared/business_logic/models/res/gift_res.dart b/lib/shared/business_logic/models/res/gift_res.dart index ae63b1d..3b2e328 100644 --- a/lib/shared/business_logic/models/res/gift_res.dart +++ b/lib/shared/business_logic/models/res/gift_res.dart @@ -7,6 +7,7 @@ // special : "" // type : "" // giftTab : "" +// relationType : "" // standardId : 0 // explanationGift : false // account : "" @@ -26,6 +27,7 @@ class SocialChatGiftRes { String? special, String? type, String? giftTab, + String? relationType, String? standardId, String? jumpUrl, String? bannerUrl, @@ -48,6 +50,7 @@ class SocialChatGiftRes { _special = special; _type = type; _giftTab = giftTab; + _relationType = relationType; _standardId = standardId; _jumpUrl = jumpUrl; _bannerUrl = bannerUrl; @@ -110,6 +113,7 @@ class SocialChatGiftRes { _special = _giftResString(json['special'] ?? giftConfig?['special']); _type = _giftResString(json['type'] ?? giftConfig?['type']); _giftTab = _giftResString(json['giftTab'] ?? giftConfig?['giftTab']); + _relationType = _giftResRelationType(json, giftConfig); _standardId = _giftResString( json['standardId'] ?? giftConfig?['standardId'], ); @@ -147,6 +151,7 @@ class SocialChatGiftRes { String? _special; String? _type; String? _giftTab; + String? _relationType; String? _standardId; String? _jumpUrl; String? _bannerUrl; @@ -169,6 +174,7 @@ class SocialChatGiftRes { String? special, String? type, String? giftTab, + String? relationType, String? standardId, String? jumpUrl, String? bannerUrl, @@ -191,6 +197,7 @@ class SocialChatGiftRes { special: special ?? _special, type: type ?? _type, giftTab: giftTab ?? _giftTab, + relationType: relationType ?? _relationType, standardId: standardId ?? _standardId, jumpUrl: jumpUrl ?? _jumpUrl, bannerUrl: bannerUrl ?? _bannerUrl, @@ -213,6 +220,7 @@ class SocialChatGiftRes { String? get special => _special; String? get type => _type; String? get giftTab => _giftTab; + String? get relationType => _relationType; String? get standardId => _standardId; String? get jumpUrl => _jumpUrl; String? get bannerUrl => _bannerUrl; @@ -237,6 +245,7 @@ class SocialChatGiftRes { map['special'] = _special; map['type'] = _type; map['giftTab'] = _giftTab; + map['relationType'] = _relationType; map['standardId'] = _standardId; map['jumpUrl'] = _jumpUrl; map['bannerUrl'] = _bannerUrl; @@ -263,6 +272,32 @@ String? _giftResString(dynamic value) { return value.toString(); } +String? _giftResRelationType( + Map json, + Map? giftConfig, +) { + const keys = [ + 'relationType', + 'relation_type', + 'cpRelationType', + 'cp_relation_type', + 'relationshipType', + 'relationship_type', + 'relation', + 'cpType', + 'cp_type', + ]; + for (final key in keys) { + for (final source in ?>[json, giftConfig]) { + final value = _giftResString(source?[key])?.trim(); + if (value != null && value.isNotEmpty) { + return value; + } + } + } + return null; +} + num? _giftResNum(dynamic value) { if (value == null) return null; if (value is num) return value; diff --git a/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart b/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart index 9399e9e..8e7fc86 100644 --- a/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart +++ b/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart @@ -8,7 +8,9 @@ class SCCpRightsConfigRes { }) : levels = levels ?? const []; factory SCCpRightsConfigRes.fromJson(dynamic json) { - final source = json is Map ? json : const {}; + final rawSource = json is Map ? json : const {}; + final body = rawSource['body'] ?? rawSource['data']; + final source = body is Map ? body : rawSource; final rawLevels = source['levels'] ?? source['levelList'] ?? source['configs']; return SCCpRightsConfigRes( diff --git a/lib/shared/business_logic/models/res/sc_first_recharge_reward_res.dart b/lib/shared/business_logic/models/res/sc_first_recharge_reward_res.dart new file mode 100644 index 0000000..ab9f44c --- /dev/null +++ b/lib/shared/business_logic/models/res/sc_first_recharge_reward_res.dart @@ -0,0 +1,384 @@ +class SCFirstRechargeRewardHomeRes { + const SCFirstRechargeRewardHomeRes({ + required this.configured, + required this.enabled, + required this.activityStatus, + required this.userId, + required this.sysOrigin, + required this.hasRewardRecord, + required this.rewarded, + required this.levelConfigs, + this.rewardStatus = '', + this.matchedLevel, + this.rechargeAmount = '', + this.rechargeAmountCents = 0, + this.rewardGroupId = '', + this.rewardGroupName = '', + this.rewardItems = const [], + }); + + factory SCFirstRechargeRewardHomeRes.fromJson(dynamic json) { + final map = _asMap(json); + return SCFirstRechargeRewardHomeRes( + configured: _readBool(map['configured']), + enabled: _readBool(map['enabled']), + activityStatus: _readString(map['activityStatus']), + userId: _readString(map['userId']), + sysOrigin: _readString(map['sysOrigin']), + hasRewardRecord: _readBool(map['hasRewardRecord']), + rewarded: _readBool(map['rewarded']), + levelConfigs: + _readList( + map['levelConfigs'], + ).map(SCFirstRechargeLevelConfig.fromJson).toList(), + rewardStatus: _readString(map['rewardStatus']), + matchedLevel: + map['matchedLevel'] == null + ? null + : SCFirstRechargeLevelConfig.fromJson(map['matchedLevel']), + rechargeAmount: _readString(map['rechargeAmount']), + rechargeAmountCents: _readInt(map['rechargeAmountCents']), + rewardGroupId: _readString(map['rewardGroupId']), + rewardGroupName: _readString(map['rewardGroupName']), + rewardItems: + _readList( + map['rewardItems'], + ).map(SCFirstRechargeRewardItem.fromJson).toList(), + ); + } + + final bool configured; + final bool enabled; + final String activityStatus; + final String userId; + final String sysOrigin; + final bool hasRewardRecord; + final bool rewarded; + final List levelConfigs; + final String rewardStatus; + final SCFirstRechargeLevelConfig? matchedLevel; + final String rechargeAmount; + final int rechargeAmountCents; + final String rewardGroupId; + final String rewardGroupName; + final List rewardItems; + + List get availableLevelConfigs { + final levels = levelConfigs.where((level) => level.enabled).toList(); + levels.sort((a, b) => a.level.compareTo(b.level)); + return levels; + } + + bool get shouldShowRewardDialog { + return configured && + enabled && + activityStatus.toUpperCase() == 'ONGOING' && + !rewarded && + availableLevelConfigs.isNotEmpty; + } +} + +class SCFirstRechargeLevelConfig { + const SCFirstRechargeLevelConfig({ + required this.id, + required this.level, + required this.rechargeAmount, + required this.rechargeAmountCents, + required this.rewardGroupId, + required this.rewardGroupName, + required this.rewardItems, + required this.enabled, + required this.reached, + required this.current, + this.discountText = '', + this.totalValueText = '', + this.productPackage = '', + }); + + factory SCFirstRechargeLevelConfig.fromJson(dynamic json) { + final map = _asMap(json); + return SCFirstRechargeLevelConfig( + id: _readString(map['id']), + level: _readInt(map['level']), + rechargeAmount: _readString(map['rechargeAmount']), + rechargeAmountCents: _readInt(map['rechargeAmountCents']), + rewardGroupId: _readString(map['rewardGroupId']), + rewardGroupName: _readString(map['rewardGroupName']), + rewardItems: + _readList( + map['rewardItems'], + ).map(SCFirstRechargeRewardItem.fromJson).toList(), + enabled: _readBool(map['enabled'], fallback: true), + reached: _readBool(map['reached']), + current: _readBool(map['current']), + discountText: _readDiscountText(map), + totalValueText: _readTotalValueText(map), + productPackage: _firstString(map, const [ + 'productPackage', + 'productId', + 'googleProductId', + 'googleProductPackage', + ]), + ); + } + + final String id; + final int level; + final String rechargeAmount; + final int rechargeAmountCents; + final String rewardGroupId; + final String rewardGroupName; + final List rewardItems; + final bool enabled; + final bool reached; + final bool current; + final String discountText; + final String totalValueText; + final String productPackage; +} + +class SCFirstRechargeRewardItem { + const SCFirstRechargeRewardItem({ + required this.id, + required this.type, + required this.name, + required this.content, + required this.quantity, + required this.cover, + this.badgeText = '', + this.days = 0, + }); + + factory SCFirstRechargeRewardItem.fromJson(dynamic json) { + final map = _asMap(json); + final content = _readString(map['content']); + final explicitDays = _firstInt(map, const [ + 'days', + 'day', + 'durationDays', + 'validDays', + 'validityDays', + ]); + return SCFirstRechargeRewardItem( + id: _readString(map['id']), + type: _readString(map['type']), + name: _readString(map['name']), + content: content, + quantity: _readInt(map['quantity']), + cover: _readString(map['cover']), + badgeText: _firstString(map, const [ + 'badgeText', + 'cornerText', + 'tagText', + 'durationText', + 'validityText', + ]), + days: explicitDays > 0 ? explicitDays : _readDaysFromText(content), + ); + } + + final String id; + final String type; + final String name; + final String content; + final int quantity; + final String cover; + final String badgeText; + final int days; + + bool get isCoinReward { + final normalizedType = type.toUpperCase(); + return normalizedType.contains('COIN') || + normalizedType.contains('GOLD') || + normalizedType.contains('CANDY'); + } + + String get displayTitle { + if (isCoinReward && quantity > 0) { + return '${_formatNumber(quantity)} Coins'; + } + return _firstNonEmpty([name, content, quantity > 0 ? 'x$quantity' : '']); + } + + String get displayBadgeText { + if (isCoinReward) { + return ''; + } + if (badgeText.trim().isNotEmpty) { + return badgeText.trim(); + } + if (days > 0) { + return '${days}Days'; + } + if (quantity > 1) { + return 'x$quantity'; + } + return ''; + } +} + +Map _asMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + return const {}; +} + +List _readList(dynamic value) { + if (value is List) { + return value; + } + return const []; +} + +String _readString(dynamic value) { + return value?.toString().trim() ?? ''; +} + +bool _readBool(dynamic value, {bool fallback = false}) { + if (value is bool) { + return value; + } + if (value is num) { + return value != 0; + } + if (value is String) { + final normalized = value.trim().toLowerCase(); + if (normalized == 'true' || normalized == '1' || normalized == 'yes') { + return true; + } + if (normalized == 'false' || normalized == '0' || normalized == 'no') { + return false; + } + } + return fallback; +} + +int _readInt(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + return int.tryParse(value.trim()) ?? 0; + } + return 0; +} + +int _firstInt(Map map, List keys) { + for (final key in keys) { + final value = _readInt(map[key]); + if (value > 0) { + return value; + } + } + return 0; +} + +String _firstString(Map map, List keys) { + for (final key in keys) { + final value = _readString(map[key]); + if (value.isNotEmpty) { + return value; + } + } + return ''; +} + +String _firstNonEmpty(List values) { + for (final value in values) { + final text = value.trim(); + if (text.isNotEmpty) { + return text; + } + } + return ''; +} + +String _readDiscountText(Map map) { + for (final key in const [ + 'discountText', + 'discount', + 'discountPercent', + 'offPercent', + 'percentOff', + 'off', + ]) { + final value = map[key]; + if (value is num) { + final percent = value <= 1 ? (value * 100).round() : value.round(); + if (percent > 0) { + return '$percent%'; + } + } + final text = _readString(value); + if (text.isEmpty) { + continue; + } + if (text.contains('%')) { + return text; + } + final numeric = num.tryParse(text); + if (numeric != null) { + final percent = numeric <= 1 ? (numeric * 100).round() : numeric.round(); + return '$percent%'; + } + return text; + } + return ''; +} + +String _readTotalValueText(Map map) { + final direct = _firstString(map, const [ + 'totalValueText', + 'totalRewardValueText', + 'rewardValueText', + ]); + if (direct.isNotEmpty) { + return direct; + } + for (final key in const ['totalValue', 'totalRewardValue', 'rewardValue']) { + final value = map[key]; + if (value is num && value > 0) { + return 'Total value of gifts: ${_formatNumber(value)} gold coins'; + } + final text = _readString(value); + if (text.isNotEmpty) { + final numeric = num.tryParse(text); + if (numeric != null) { + return 'Total value of gifts: ${_formatNumber(numeric)} gold coins'; + } + return text; + } + } + return ''; +} + +int _readDaysFromText(String text) { + final match = RegExp( + r'(\d+)\s*(d|day|days)', + caseSensitive: false, + ).firstMatch(text); + if (match == null) { + return 0; + } + return int.tryParse(match.group(1) ?? '') ?? 0; +} + +String _formatNumber(num value) { + final text = value.toInt().toString(); + final buffer = StringBuffer(); + for (var i = 0; i < text.length; i++) { + final remaining = text.length - i; + buffer.write(text[i]); + if (remaining > 1 && remaining % 3 == 1) { + buffer.write(','); + } + } + return buffer.toString(); +} diff --git a/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart b/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart index e646198..893b94a 100644 --- a/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart +++ b/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart @@ -1,104 +1,142 @@ -/// account : "" -/// countryCode : "" -/// countryName : "" -/// userAvatar : "" -/// userNickname : "" -/// actualAccount : "" - -class SCSystemInvitMessageRes { - SCSystemInvitMessageRes({ - String? account, - String? countryCode, - String? countryName, - String? userAvatar, - String? userNickname, - String? applyType, - String? content, - String? actualAccount, - String? relationType, - int? dismissEndTime, - String? giftCover, - int? giftCount, - }) { - _account = account; - _countryCode = countryCode; - _countryName = countryName; - _userAvatar = userAvatar; - _userNickname = userNickname; - _actualAccount = actualAccount; - _applyType = applyType; - _content = content; - _relationType = relationType; - _dismissEndTime = dismissEndTime; - _giftCover = giftCover; - _giftCount = giftCount; - } - - SCSystemInvitMessageRes.fromJson(dynamic json) { - _account = json['account']; - _countryCode = json['countryCode']; - _countryName = json['countryName']; - _userAvatar = json['userAvatar']; - _userNickname = json['userNickname']; - _actualAccount = json['actualAccount']; - _applyType = json['applyType']; - _content = json['content']; - _relationType = json['relationType']; - _dismissEndTime = _asInt(json['dismissEndTime']); - _giftCover = json['giftCover']; - _giftCount = _asInt(json['giftCount']); - } - - static int? _asInt(dynamic value) { - if (value is int) { - return value; - } - if (value is num) { - return value.toInt(); - } - return int.tryParse(value?.toString() ?? ''); - } - - String? _account; - String? _countryCode; - String? _countryName; - String? _userAvatar; - String? _userNickname; - String? _actualAccount; - String? _applyType; - String? _content; - String? _relationType; - int? _dismissEndTime; - String? _giftCover; - int? _giftCount; - - String? get account => _account; - String? get countryCode => _countryCode; - String? get countryName => _countryName; - String? get userAvatar => _userAvatar; - String? get userNickname => _userNickname; - String? get actualAccount => _actualAccount; - String? get applyType => _applyType; - String? get content => _content; - String? get relationType => _relationType; - int? get dismissEndTime => _dismissEndTime; - String? get giftCover => _giftCover; - int? get giftCount => _giftCount; - - Map toJson() { - final map = {}; - map['account'] = _account; - map['countryCode'] = _countryCode; - map['countryName'] = _countryName; - map['userAvatar'] = _userAvatar; - map['userNickname'] = _userNickname; - map['actualAccount'] = _actualAccount; - map['applyType'] = _applyType; - map['content'] = _content; - map['relationType'] = _relationType; - map['dismissEndTime'] = _dismissEndTime; - map['giftCover'] = _giftCover; - map['giftCount'] = _giftCount; - return map; - } -} +// account : "" +// countryCode : "" +// countryName : "" +// userAvatar : "" +// userNickname : "" +// actualAccount : "" + +class SCSystemInvitMessageRes { + SCSystemInvitMessageRes({ + String? account, + String? countryCode, + String? countryName, + String? userAvatar, + String? userHeaddress, + String? userHeaddressCover, + String? userNickname, + String? applyType, + String? content, + String? actualAccount, + String? relationType, + int? dismissEndTime, + String? giftCover, + int? giftCount, + }) { + _account = account; + _countryCode = countryCode; + _countryName = countryName; + _userAvatar = userAvatar; + _userHeaddress = userHeaddress; + _userHeaddressCover = userHeaddressCover; + _userNickname = userNickname; + _actualAccount = actualAccount; + _applyType = applyType; + _content = content; + _relationType = relationType; + _dismissEndTime = dismissEndTime; + _giftCover = giftCover; + _giftCount = giftCount; + } + + SCSystemInvitMessageRes.fromJson(dynamic json) { + _account = json['account']; + _countryCode = json['countryCode']; + _countryName = json['countryName']; + _userAvatar = json['userAvatar']; + _userHeaddress = _firstNonBlank(json, const [ + 'userHeaddress', + 'userAvatarFrame', + 'userAvatarFrameUrl', + 'headdress', + 'headdressUrl', + 'avatarFrame', + 'avatarFrameUrl', + ]); + _userHeaddressCover = _firstNonBlank(json, const [ + 'userHeaddressCover', + 'userAvatarFrameCover', + 'headdressCover', + 'avatarFrameCover', + ]); + _userNickname = json['userNickname']; + _actualAccount = json['actualAccount']; + _applyType = json['applyType']; + _content = json['content']; + _relationType = json['relationType']; + _dismissEndTime = _asInt(json['dismissEndTime']); + _giftCover = json['giftCover']; + _giftCount = _asInt(json['giftCount']); + } + + static int? _asInt(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ''); + } + + String? _account; + String? _countryCode; + String? _countryName; + String? _userAvatar; + String? _userHeaddress; + String? _userHeaddressCover; + String? _userNickname; + String? _actualAccount; + String? _applyType; + String? _content; + String? _relationType; + int? _dismissEndTime; + String? _giftCover; + int? _giftCount; + + String? get account => _account; + String? get countryCode => _countryCode; + String? get countryName => _countryName; + String? get userAvatar => _userAvatar; + String? get userHeaddress => _userHeaddress; + String? get userHeaddressCover => _userHeaddressCover; + String? get userNickname => _userNickname; + String? get actualAccount => _actualAccount; + String? get applyType => _applyType; + String? get content => _content; + String? get relationType => _relationType; + int? get dismissEndTime => _dismissEndTime; + String? get giftCover => _giftCover; + int? get giftCount => _giftCount; + + Map toJson() { + final map = {}; + map['account'] = _account; + map['countryCode'] = _countryCode; + map['countryName'] = _countryName; + map['userAvatar'] = _userAvatar; + map['userHeaddress'] = _userHeaddress; + map['userHeaddressCover'] = _userHeaddressCover; + map['userNickname'] = _userNickname; + map['actualAccount'] = _actualAccount; + map['applyType'] = _applyType; + map['content'] = _content; + map['relationType'] = _relationType; + map['dismissEndTime'] = _dismissEndTime; + map['giftCover'] = _giftCover; + map['giftCount'] = _giftCount; + return map; + } +} + +String? _firstNonBlank(dynamic json, List keys) { + if (json is! Map) { + return null; + } + for (final key in keys) { + final text = json[key]?.toString().trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return null; +} diff --git a/lib/shared/business_logic/repositories/config_repository.dart b/lib/shared/business_logic/repositories/config_repository.dart index 222d14b..705df5d 100644 --- a/lib/shared/business_logic/repositories/config_repository.dart +++ b/lib/shared/business_logic/repositories/config_repository.dart @@ -5,6 +5,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_mifa_pay_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_google_pay_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_entry_popup_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_first_recharge_reward_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_level_config_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_product_config_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_start_page_res.dart'; @@ -46,6 +47,9 @@ abstract class SocialChatConfigRepository { ///获取商品配置 Future> productConfig(); + ///首充奖励首页 + Future firstRechargeRewardHome(); + ///谷歌内购 Future googlePay( String product, 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 aa04a5c..443a203 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -30,6 +30,7 @@ class OverlayManager { static const Duration _rocketFloatingFallbackDedupWindow = Duration( seconds: 30, ); + static const int _maxStackedFloatingVisibleCount = 2; static const List _stackedFloatingOrder = [5, 1, 4, 3]; static const double _stackedFloatingBaseTop = 70; static const double _stackedFloatingGap = 8; @@ -331,7 +332,9 @@ class OverlayManager { bool _canPlayMessage(SCFloatingMessage message) { final slotKey = _stackSlotKeyFor(message); if (slotKey != null) { - return !_isPlaying && !_stackedOverlayEntries.containsKey(slotKey); + return !_isPlaying && + !_stackedOverlayEntries.containsKey(slotKey) && + _stackedOverlayEntries.length < _maxStackedFloatingVisibleCount; } return !_isPlaying && _stackedOverlayEntries.isEmpty; } @@ -507,29 +510,12 @@ class OverlayManager { if (previousMessage != null) { visualTop += _stackedVisualHeightFor(previousMessage) + _stackedFloatingGap; - } else if (_shouldReserveStackedSlot(message, key)) { - visualTop += _stackedReservedVisualHeightFor(key) + _stackedFloatingGap; } } final wrapperTop = visualTop - _stackedInternalTopFor(message); return wrapperTop < 0 ? 0 : wrapperTop; } - bool _shouldReserveStackedSlot(SCFloatingMessage message, int slotKey) { - return _homeRootTabsVisible && - _isRegionRoomRocketMessage(message) && - slotKey == 4; - } - - double _stackedReservedVisualHeightFor(int slotKey) { - switch (slotKey) { - case 4: - return 66; - default: - return 0; - } - } - double _stackedVisualHeightFor(SCFloatingMessage message) { switch (message.type) { case 0: diff --git a/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart index 0dc5bd2..6f4195c 100644 --- a/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/business_logic/models/res/sc_entry_popup_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_first_recharge_reward_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_google_pay_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_mifa_pay_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_product_config_res.dart'; @@ -167,6 +168,17 @@ class SCConfigRepositoryImp implements SocialChatConfigRepository { return result; } + ///go/app/first-recharge-reward/home + @override + Future firstRechargeRewardHome() async { + final result = await http.get( + "/go/app/first-recharge-reward/home", + extra: const {BaseNetworkClient.silentErrorToastKey: true}, + fromJson: (json) => SCFirstRechargeRewardHomeRes.fromJson(json), + ); + return result; + } + ///order/purchase-pay/google @override Future googlePay( diff --git a/lib/shared/tools/sc_banner_utils.dart b/lib/shared/tools/sc_banner_utils.dart index 48f0258..e337bb0 100644 --- a/lib/shared/tools/sc_banner_utils.dart +++ b/lib/shared/tools/sc_banner_utils.dart @@ -1,52 +1,73 @@ -import 'package:flutter/cupertino.dart'; -import 'package:yumi/shared/tools/sc_room_utils.dart'; -import 'package:yumi/shared/tools/sc_string_utils.dart'; -import 'package:yumi/shared/tools/sc_h5_url_utils.dart'; -import 'package:yumi/shared/tools/sc_url_launcher_utils.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; -import 'package:yumi/modules/index/main_route.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; - -import '../../shared/data_sources/models/enum/sc_banner_open_type.dart'; - -class SCBannerUtils { - static Future openBanner( - SCIndexBannerRes item, - BuildContext context, - ) async { - if (item.content == SCBannerOpenType.ENTER_ROOM.name) { - var params = item.params; - if (params != null && params.isNotEmpty) { - SCRoomUtils.goRoom(params, context); - } - } else { - var params = item.params; - if (params == null || params.isEmpty) { - return; - } - - if (SCUrlLauncherUtils.shouldOpenExternally(params)) { - final launched = await SCUrlLauncherUtils.launchExternal(params); - if (launched) { - return; - } - if (!context.mounted) { - return; - } - } - - if (SCStringUtils.checkIfUrl(params)) { - final h5Url = _appendToken(params); - SCNavigatorUtils.push( - context, - "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(h5Url)}&showTitle=false", - replace: false, - ); - } - } - } - - static String _appendToken(String url) { - return SCH5UrlUtils.appendToken(url); - } -} +import 'package:flutter/material.dart'; +import 'package:yumi/modules/webview/webview_page.dart'; +import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/shared/tools/sc_string_utils.dart'; +import 'package:yumi/shared/tools/sc_h5_url_utils.dart'; +import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; +import 'package:yumi/shared/tools/sc_url_launcher_utils.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; +import 'package:yumi/modules/index/main_route.dart'; +import 'package:yumi/app/routes/sc_fluro_navigator.dart'; + +import '../../shared/data_sources/models/enum/sc_banner_open_type.dart'; + +class SCBannerUtils { + static Future openBanner( + SCIndexBannerRes item, + BuildContext context, { + bool openH5AsRoomPopup = false, + }) async { + if (item.content == SCBannerOpenType.ENTER_ROOM.name) { + var params = item.params; + if (params != null && params.isNotEmpty) { + SCRoomUtils.goRoom(params, context); + } + } else { + var params = item.params; + if (params == null || params.isEmpty) { + return; + } + + if (SCUrlLauncherUtils.shouldOpenExternally(params)) { + final launched = await SCUrlLauncherUtils.launchExternal(params); + if (launched) { + return; + } + if (!context.mounted) { + return; + } + } + + if (SCStringUtils.checkIfUrl(params)) { + final h5Url = _appendToken(params); + if (openH5AsRoomPopup) { + await _showRoomH5Popup(context, h5Url); + return; + } + SCNavigatorUtils.push( + context, + "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(h5Url)}&showTitle=false", + replace: false, + ); + } + } + } + + static String _appendToken(String url) { + return SCH5UrlUtils.appendToken(url); + } + + static Future _showRoomH5Popup(BuildContext context, String h5Url) { + final screenHeight = MediaQuery.sizeOf(context).height; + return showBottomInBottomDialog( + context, + SizedBox( + height: screenHeight * 0.75, + width: double.infinity, + child: WebViewPage(url: h5Url, showTitle: "false"), + ), + barrierColor: Colors.black54, + barrierDismissible: true, + ); + } +} diff --git a/lib/shared/tools/sc_cp_gift_relation_utils.dart b/lib/shared/tools/sc_cp_gift_relation_utils.dart new file mode 100644 index 0000000..eecddee --- /dev/null +++ b/lib/shared/tools/sc_cp_gift_relation_utils.dart @@ -0,0 +1,81 @@ +import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; +import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; + +bool scIsCpGiftTab(String? tabType) => + tabType?.trim().toUpperCase() == SCGiftType.CP.name; + +bool scIsCpGift(SocialChatGiftRes? gift) => scIsCpGiftTab(gift?.giftTab); + +String scCpRelationTypeForGift(SocialChatGiftRes gift) { + final explicit = _normalizeExplicitGiftRelationType(gift.relationType); + if (explicit != null) { + return explicit; + } + for (final value in [ + gift.type, + gift.giftCode, + gift.giftName, + gift.special, + gift.standardId, + ]) { + final inferred = _inferCpRelationTypeFromGiftText(value); + if (inferred != null) { + return inferred; + } + } + return "CP"; +} + +String? _normalizeExplicitGiftRelationType(String? relationType) { + final raw = relationType?.trim(); + if (raw == null || raw.isEmpty) { + return null; + } + final normalized = scNormalizeCpRelationType(raw); + if (normalized != "CP" || _isCpRelationText(raw)) { + return normalized; + } + return _inferCpRelationTypeFromGiftText(raw) ?? normalized; +} + +bool _isCpRelationText(String value) { + final upper = value.trim().toUpperCase(); + return upper == "CP" || upper == "COUPLE"; +} + +String? _inferCpRelationTypeFromGiftText(String? value) { + final upper = value?.trim().toUpperCase(); + if (upper == null || upper.isEmpty) { + return null; + } + if (_hasRelationToken(upper, const ["SIS", "SISTER", "SISTERS"])) { + return "SISTERS"; + } + if (_hasRelationToken(upper, const [ + "BRO", + "BROS", + "BROTHER", + "BROTHERS", + "SWORN_BRO", + "SWORN_BROS", + "SWORN_BROTHER", + "SWORN_BROTHERS", + ])) { + return "BROTHER"; + } + if (_hasRelationToken(upper, const ["CP", "COUPLE"])) { + return "CP"; + } + return null; +} + +bool _hasRelationToken(String upperText, List tokens) { + for (final token in tokens) { + final escaped = RegExp.escape(token); + if (RegExp('(^|[^A-Z0-9])$escaped([^A-Z0-9]|\$)').hasMatch(upperText)) { + return true; + } + } + return false; +} diff --git a/lib/shared/tools/sc_cp_relation_notice_utils.dart b/lib/shared/tools/sc_cp_relation_notice_utils.dart index 8261334..cd15ed3 100644 --- a/lib/shared/tools/sc_cp_relation_notice_utils.dart +++ b/lib/shared/tools/sc_cp_relation_notice_utils.dart @@ -1,11 +1,16 @@ String scNormalizeCpRelationType(String? relationType) { final value = relationType?.trim().toUpperCase() ?? ""; switch (value) { + case "BRO": + case "BROS": case "BROTHER": case "BROTHERS": + case "SWORN_BRO": + case "SWORN_BROS": case "SWORN_BROTHER": case "SWORN_BROTHERS": return "BROTHER"; + case "SIS": case "SISTER": case "SISTERS": return "SISTERS"; diff --git a/lib/shared/tools/sc_dialog_utils.dart b/lib/shared/tools/sc_dialog_utils.dart index 42b7be7..e95700e 100644 --- a/lib/shared/tools/sc_dialog_utils.dart +++ b/lib/shared/tools/sc_dialog_utils.dart @@ -6,237 +6,15 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; -import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; -import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; -import 'package:yumi/modules/wallet/wallet_route.dart'; import 'package:yumi/ui_kit/widgets/banner/index_banner_page.dart'; -import 'package:yumi/ui_kit/widgets/countdown_timer.dart'; +import 'package:yumi/ui_kit/widgets/first_recharge/first_recharge_dialog.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; class SCDialogUtils { - static showFirstRechargeDialog(BuildContext context) { - SmartDialog.show( - tag: "showFirstRecharge", - alignment: Alignment.center, - animationType: SmartAnimationType.fade, - builder: (_) { - return Center( - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - SizedBox( - height: 530.w, - width: ScreenUtil().screenWidth * 0.86, - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - "sc_images/index/sc_icon_first_recharge_bg.png", - ), - fit: BoxFit.fill, - ), - ), - child: Column( - children: [ - SizedBox(height: 138.w), - GestureDetector( - child: Row( - textDirection: TextDirection.ltr, - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - "sc_images/index/sc_icon_my_rechage_title.png", - height: 25.w, - ), - Transform.translate( - offset: Offset(-3.w, 0), - child: text( - "${AccountStorage().getCurrentUser()?.userProfile?.firstRechargeAmount}", - textColor: Colors.white, - fontSize: 14.sp, - ), - ), - ], - ), - onTap: () { - SCRoomUtils.closeAllDialogs(); - SCNavigatorUtils.push( - context, - WalletRoute.recharge, - replace: false, - ); - }, - ), - SizedBox(height: 55.w), - (AccountStorage() - .getCurrentUser() - ?.userProfile - ?.firstRechargeAmount ?? - 0) < - 0.99 - ? Container(height: 60.w) - : Stack( - alignment: Alignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 28.w, - children: [ - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - ], - ), - Image.asset( - "sc_images/index/sc_icon_claimed_text.png", - width: 70.w, - height: 35.w, - ), - ], - ), - SizedBox(height: 40.w), - (AccountStorage() - .getCurrentUser() - ?.userProfile - ?.firstRechargeAmount ?? - 0) < - 4.99 - ? Container(height: 60.w) - : Stack( - alignment: Alignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 28.w, - children: [ - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - ], - ), - Image.asset( - "sc_images/index/sc_icon_claimed_text.png", - width: 70.w, - height: 35.w, - ), - ], - ), - SizedBox(height: 48.w), - (AccountStorage() - .getCurrentUser() - ?.userProfile - ?.firstRechargeAmount ?? - 0) < - 9.99 - ? Container(height: 60.w) - : Stack( - alignment: Alignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 28.w, - children: [ - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(10.w), - ), - height: 60.w, - width: 60.w, - ), - ], - ), - Image.asset( - "sc_images/index/sc_icon_claimed_text.png", - width: 70.w, - height: 35.w, - ), - ], - ), - ], - ), - ), - ), - PositionedDirectional( - bottom: 7.w, - child: CountdownTimer( - expiryDate: DateTime.fromMillisecondsSinceEpoch( - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.firstRechargeEndTime ?? - 0, - ), - color: Colors.white, - fontSize: 15.sp, - ), - ), - ], - ), - ); - }, - ); + static Future showFirstRechargeDialog(BuildContext context) { + return FirstRechargeDialog.showFromRemote(context); } static void showDynamicCommentOptDialog( @@ -276,7 +54,7 @@ class SCDialogUtils { ), ), onTap: () { - reportCallback?.call(); + reportCallback.call(); SmartDialog.dismiss(tag: "showDynamicCommentOptDialog"); }, ), @@ -297,7 +75,7 @@ class SCDialogUtils { ), ), onTap: () { - deleteCallback?.call(); + deleteCallback.call(); SmartDialog.dismiss(tag: "showDynamicCommentOptDialog"); }, ), diff --git a/lib/shared/tools/sc_gift_vap_svga_manager.dart b/lib/shared/tools/sc_gift_vap_svga_manager.dart index fb4ffd3..526bc98 100644 --- a/lib/shared/tools/sc_gift_vap_svga_manager.dart +++ b/lib/shared/tools/sc_gift_vap_svga_manager.dart @@ -516,6 +516,10 @@ class SCGiftVapSvgaManager { ); return; } + if (SCRoomEffectScheduler().isSuppressed) { + _log('play ignored because room effects are suppressed path=$path'); + return; + } final resolvedPriority = resolveEffectPriority( priority: priority, type: type, diff --git a/lib/shared/tools/sc_room_effect_scheduler.dart b/lib/shared/tools/sc_room_effect_scheduler.dart index 46238b3..415cd33 100644 --- a/lib/shared/tools/sc_room_effect_scheduler.dart +++ b/lib/shared/tools/sc_room_effect_scheduler.dart @@ -19,9 +19,29 @@ class SCRoomEffectScheduler { Timer? _deferredDrainTimer; int _pendingHighCostTaskCount = 0; int _nextTaskId = 0; + int _suppressedCount = 0; bool get hasActiveHighCostEffects => _pendingHighCostTaskCount > 0; + bool get isSuppressed => _suppressedCount > 0; + + void beginSuppressEffects({String reason = 'unknown'}) { + _suppressedCount += 1; + clearDeferredTasks(reason: reason); + clearHighCostTasks(reason: reason); + _log('begin_suppress reason=$reason count=$_suppressedCount'); + } + + void endSuppressEffects({String reason = 'unknown'}) { + if (_suppressedCount > 0) { + _suppressedCount -= 1; + } + _log('end_suppress reason=$reason count=$_suppressedCount'); + if (!isSuppressed) { + _scheduleDeferredDrainIfNeeded(); + } + } + void registerHighCostTaskQueued({String? debugLabel}) { _pendingHighCostTaskCount += 1; _cancelDeferredDrain(); @@ -59,6 +79,10 @@ class SCRoomEffectScheduler { required String debugLabel, required VoidCallback action, }) { + if (isSuppressed) { + _log('drop_suppressed task=$debugLabel'); + return; + } if (!hasActiveHighCostEffects) { action(); return; @@ -108,7 +132,12 @@ class SCRoomEffectScheduler { ); try { task.action(); - } catch (error, stackTrace) {} + } catch (error, stackTrace) { + _log( + 'deferred task failed task=${task.debugLabel} id=${task.id} ' + 'error=$error stack=$stackTrace', + ); + } _scheduleDeferredDrainIfNeeded(); } diff --git a/lib/shared/tools/sc_room_top_layer_guard.dart b/lib/shared/tools/sc_room_top_layer_guard.dart new file mode 100644 index 0000000..e926b9e --- /dev/null +++ b/lib/shared/tools/sc_room_top_layer_guard.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; +import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; +import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; +import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_widget.dart'; + +class SCRoomTopLayerGuard { + static const int _maxDeferredActions = 16; + static const String _redPacketOpenDialogTag = 'showRoomRedPacketOpenDialog'; + static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog'; + static const String _roomRocketPagLaunchDialogTag = + 'showRoomRocketPagLaunchDialog'; + + static final SCRoomTopLayerGuard _instance = SCRoomTopLayerGuard._internal(); + + factory SCRoomTopLayerGuard() => _instance; + + SCRoomTopLayerGuard._internal(); + + final Queue _deferredActions = Queue(); + final ValueNotifier _activeListenable = ValueNotifier(false); + int _activeCount = 0; + + bool get isActive => _activeCount > 0; + + ValueListenable get activeListenable => _activeListenable; + + SCRoomTopLayerGuardLease acquire({ + required String reason, + Set keepDialogTags = const {}, + }) { + _begin(reason, keepDialogTags: keepDialogTags); + return SCRoomTopLayerGuardLease._(this, reason); + } + + void runWhenInactive(VoidCallback action) { + if (!isActive) { + action(); + return; + } + while (_deferredActions.length >= _maxDeferredActions) { + _deferredActions.removeFirst(); + } + _deferredActions.add(action); + } + + void _begin(String reason, {Set keepDialogTags = const {}}) { + _activeCount += 1; + if (_activeCount != 1) { + return; + } + _activeListenable.value = true; + OverlayManager().beginSuppressFloatingScreens(reason: reason); + SCRoomEffectScheduler().beginSuppressEffects(reason: reason); + SCRoomEffectScheduler().clearDeferredTasks(reason: reason); + RoomEntranceHelper.clearQueue(); + SCGiftVapSvgaManager().stopPlayback(); + SCGiftVapSvgaManager().pauseAnim(); + if (!keepDialogTags.contains(_redPacketOpenDialogTag)) { + unawaited(SmartDialog.dismiss(tag: _redPacketOpenDialogTag)); + } + if (!keepDialogTags.contains(_roomRocketRewardDialogTag)) { + unawaited(SmartDialog.dismiss(tag: _roomRocketRewardDialogTag)); + } + if (!keepDialogTags.contains(_roomRocketPagLaunchDialogTag)) { + unawaited(SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag)); + } + } + + void _release(String reason) { + if (_activeCount <= 0) { + return; + } + _activeCount -= 1; + if (_activeCount != 0) { + return; + } + OverlayManager().endSuppressFloatingScreens(reason: reason); + SCRoomEffectScheduler().endSuppressEffects(reason: reason); + SCGiftVapSvgaManager().resumeAnim(); + _activeListenable.value = false; + _drainDeferredActions(); + } + + void _drainDeferredActions() { + if (_deferredActions.isEmpty) { + return; + } + final actions = List.from(_deferredActions); + _deferredActions.clear(); + scheduleMicrotask(() { + for (final action in actions) { + if (isActive) { + runWhenInactive(action); + continue; + } + action(); + } + }); + } +} + +class SCRoomTopLayerSuppressor extends StatefulWidget { + const SCRoomTopLayerSuppressor({ + super.key, + required this.reason, + required this.child, + this.keepDialogTags = const {}, + }); + + final String reason; + final Widget child; + final Set keepDialogTags; + + @override + State createState() => + _SCRoomTopLayerSuppressorState(); +} + +class _SCRoomTopLayerSuppressorState extends State { + SCRoomTopLayerGuardLease? _lease; + + @override + void initState() { + super.initState(); + _acquire(); + } + + @override + void didUpdateWidget(covariant SCRoomTopLayerSuppressor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.reason == widget.reason && + setEquals(oldWidget.keepDialogTags, widget.keepDialogTags)) { + return; + } + _release(); + _acquire(); + } + + @override + void dispose() { + _release(); + super.dispose(); + } + + void _acquire() { + _lease = SCRoomTopLayerGuard().acquire( + reason: widget.reason, + keepDialogTags: widget.keepDialogTags, + ); + } + + void _release() { + _lease?.release(); + _lease = null; + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +class SCRoomTopLayerGuardLease { + SCRoomTopLayerGuardLease._(this._guard, this.reason); + + final SCRoomTopLayerGuard _guard; + final String reason; + bool _released = false; + + void release() { + if (_released) { + return; + } + _released = true; + _guard._release(reason); + } +} diff --git a/lib/shared/tools/sc_room_utils.dart b/lib/shared/tools/sc_room_utils.dart index 64b64d1..0f8244c 100644 --- a/lib/shared/tools/sc_room_utils.dart +++ b/lib/shared/tools/sc_room_utils.dart @@ -17,6 +17,7 @@ import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; typedef SCRoomUtils = SCChatRoomHelper; @@ -185,73 +186,76 @@ class SCChatRoomHelper { alignment: Alignment.center, animationType: SmartAnimationType.fade, builder: (_) { - return Container( - width: ScreenUtil().screenWidth * 0.75, - height: 120.w, - padding: EdgeInsets.symmetric(horizontal: 10.w), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.all(Radius.circular(12.w)), - ), - child: Column( - children: [ - SizedBox(height: 8.w), - Row( - children: [ - Spacer(), - GestureDetector( - child: Icon(Icons.close, color: Colors.black, size: 20.w), - onTap: () { - SmartDialog.dismiss(tag: "showGoToRecharge"); - }, - ), - SizedBox(width: 10.w), - ], - ), - Row( - children: [ - Expanded( + return SCRoomTopLayerSuppressor( + reason: 'room_recharge_prompt_dialog', + child: Container( + width: ScreenUtil().screenWidth * 0.75, + height: 120.w, + padding: EdgeInsets.symmetric(horizontal: 10.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(12.w)), + ), + child: Column( + children: [ + SizedBox(height: 8.w), + Row( + children: [ + Spacer(), + GestureDetector( + child: Icon(Icons.close, color: Colors.black, size: 20.w), + onTap: () { + SmartDialog.dismiss(tag: "showGoToRecharge"); + }, + ), + SizedBox(width: 10.w), + ], + ), + Row( + children: [ + Expanded( + child: text( + SCAppLocalizations.of( + context, + )!.insufhcientGoldsGoToRecharge, + textColor: Colors.black, + textAlign: TextAlign.center, + fontSize: 13.sp, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + SizedBox(height: 10.w), + GestureDetector( + child: Container( + alignment: Alignment.center, + height: 38.w, + width: 125.w, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFFEB219), Color(0xFFFF9326)], + ), + borderRadius: BorderRadius.all(Radius.circular(8.w)), + ), child: text( - SCAppLocalizations.of( - context, - )!.insufhcientGoldsGoToRecharge, - textColor: Colors.black, - textAlign: TextAlign.center, + SCAppLocalizations.of(context)!.goToRecharge, + textColor: Colors.white, fontSize: 13.sp, fontWeight: FontWeight.w600, ), ), - ], - ), - SizedBox(height: 10.w), - GestureDetector( - child: Container( - alignment: Alignment.center, - height: 38.w, - width: 125.w, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFFFEB219), Color(0xFFFF9326)], - ), - borderRadius: BorderRadius.all(Radius.circular(8.w)), - ), - child: text( - SCAppLocalizations.of(context)!.goToRecharge, - textColor: Colors.white, - fontSize: 13.sp, - fontWeight: FontWeight.w600, - ), + onTap: () { + closeAllDialogs(); + SCNavigatorUtils.push( + context, + WalletRoute.recharge, + replace: false, + ); + }, ), - onTap: () { - closeAllDialogs(); - SCNavigatorUtils.push( - context, - WalletRoute.recharge, - replace: false, - ); - }, - ), - ], + ], + ), ), ); }, diff --git a/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart b/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart new file mode 100644 index 0000000..10c0291 --- /dev/null +++ b/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart @@ -0,0 +1,944 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:cached_network_image/cached_network_image.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/services/payment/google_payment_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_first_recharge_reward_res.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart'; +import 'package:yumi/shared/tools/sc_loading_manager.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/components/sc_tts.dart'; + +enum FirstRechargeRewardStyle { coin, prop } + +class FirstRechargeReward { + const FirstRechargeReward({ + required this.title, + required this.style, + this.badgeText = '', + this.cover = '', + }); + + final String title; + final FirstRechargeRewardStyle style; + final String badgeText; + final String cover; + + bool get isCoinReward => style == FirstRechargeRewardStyle.coin; +} + +class FirstRechargePackage { + const FirstRechargePackage({ + required this.priceText, + required this.rewards, + this.discountText = '', + this.totalValueText = '', + this.rechargeAmountCents = 0, + this.productPackage = '', + }); + + final String priceText; + final String discountText; + final String totalValueText; + final int rechargeAmountCents; + final String productPackage; + final List rewards; +} + +class FirstRechargeDialogData { + const FirstRechargeDialogData({ + required this.packages, + this.timeRemainingText = '', + this.initialPackageIndex = 0, + }); + + final List packages; + final String timeRemainingText; + final int initialPackageIndex; + + factory FirstRechargeDialogData.mock() { + return const FirstRechargeDialogData( + timeRemainingText: 'Time remaining: 6 D 59 H 59 M 59S', + packages: [ + FirstRechargePackage( + priceText: r'$0.99', + discountText: '70%', + totalValueText: 'Total value of gifts: xxx gold coins', + rewards: [ + FirstRechargeReward( + title: 'NameName', + badgeText: '80000', + cover: _FirstRechargeAssets.rewardAvatarFrame, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: 'NameName', + badgeText: '240000', + cover: _FirstRechargeAssets.rewardAvatarFrame, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: '240,000 Coins', + cover: _FirstRechargeAssets.rewardCoinStack, + style: FirstRechargeRewardStyle.coin, + ), + FirstRechargeReward( + title: 'Vehicle', + badgeText: '3Days', + cover: _FirstRechargeAssets.rewardVehicle, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: 'Frame', + badgeText: '3Days', + cover: _FirstRechargeAssets.rewardFrame, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: 'Gift', + badgeText: 'x1', + cover: _FirstRechargeAssets.rewardGift, + style: FirstRechargeRewardStyle.prop, + ), + ], + ), + FirstRechargePackage( + priceText: r'$2.99', + discountText: '60%', + totalValueText: 'Total value of gifts: 720,000 gold coins', + rewards: [ + FirstRechargeReward( + title: 'Avatar Frame', + badgeText: '7Days', + cover: _FirstRechargeAssets.rewardAvatarFrame, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: 'Room Frame', + badgeText: '7Days', + cover: _FirstRechargeAssets.rewardFrame, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: '720,000 Coins', + cover: _FirstRechargeAssets.rewardCoinStack, + style: FirstRechargeRewardStyle.coin, + ), + FirstRechargeReward( + title: 'Vehicle', + badgeText: '7Days', + cover: _FirstRechargeAssets.rewardVehicle, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: 'Gift', + badgeText: 'x3', + cover: _FirstRechargeAssets.rewardGift, + style: FirstRechargeRewardStyle.prop, + ), + FirstRechargeReward( + title: 'Profile Frame', + badgeText: '7Days', + cover: _FirstRechargeAssets.rewardAvatarFrame, + style: FirstRechargeRewardStyle.prop, + ), + ], + ), + ], + ); + } + + factory FirstRechargeDialogData.fromRewardHome( + SCFirstRechargeRewardHomeRes home, + ) { + final levels = home.availableLevelConfigs.take(2).toList(); + final packages = + levels.map((level) { + return FirstRechargePackage( + priceText: _formatPriceText(level), + discountText: level.discountText, + totalValueText: level.totalValueText, + rechargeAmountCents: level.rechargeAmountCents, + productPackage: level.productPackage, + rewards: + level.rewardItems.map((item) { + return FirstRechargeReward( + title: item.displayTitle, + style: + item.isCoinReward + ? FirstRechargeRewardStyle.coin + : FirstRechargeRewardStyle.prop, + badgeText: item.displayBadgeText, + cover: item.cover, + ); + }).toList(), + ); + }).toList(); + + final currentIndex = levels.indexWhere((level) => level.current); + return FirstRechargeDialogData( + packages: packages, + initialPackageIndex: currentIndex >= 0 ? currentIndex : 0, + ); + } + + static String _formatPriceText(SCFirstRechargeLevelConfig level) { + final amount = level.rechargeAmount.trim(); + if (amount.isNotEmpty) { + return amount.startsWith(r'$') ? amount : '\$$amount'; + } + if (level.rechargeAmountCents > 0) { + return '\$${(level.rechargeAmountCents / 100).toStringAsFixed(2)}'; + } + return r'$0.00'; + } +} + +class FirstRechargeDialog extends StatefulWidget { + const FirstRechargeDialog({super.key, required this.data, this.onBuyNow}); + + static const String dialogTag = 'showFirstRechargeDialog'; + static const double _designWidth = 340; + static const double _designHeight = 528; + + final FirstRechargeDialogData data; + final ValueChanged? onBuyNow; + + static Future show( + BuildContext context, { + required FirstRechargeDialogData data, + ValueChanged? onBuyNow, + }) async { + SmartDialog.dismiss(tag: dialogTag); + await SmartDialog.show( + tag: dialogTag, + alignment: Alignment.center, + animationType: SmartAnimationType.fade, + clickMaskDismiss: true, + maskColor: Colors.black.withValues(alpha: 0.5), + builder: (_) => FirstRechargeDialog(data: data, onBuyNow: onBuyNow), + ); + } + + static Future showFromRemote(BuildContext context) async { + try { + SCLoadingManager.show(context: context); + final home = await SCConfigRepositoryImp().firstRechargeRewardHome(); + if (!context.mounted) { + return; + } + if (!home.shouldShowRewardDialog) { + SmartDialog.dismiss(tag: dialogTag); + return; + } + final data = FirstRechargeDialogData.fromRewardHome(home); + if (data.packages.isEmpty) { + SmartDialog.dismiss(tag: dialogTag); + return; + } + SCLoadingManager.hide(); + await show( + context, + data: data, + onBuyNow: (package) { + _startGooglePay(context, package); + }, + ); + } catch (e) { + SCTts.show('Failed to load first recharge rewards'); + } finally { + SCLoadingManager.hide(); + } + } + + static Future _startGooglePay( + BuildContext context, + FirstRechargePackage package, + ) async { + if (!Platform.isAndroid) { + SCTts.show('Google Play payment is only available on Android'); + return; + } + SmartDialog.dismiss(tag: dialogTag); + final processor = Provider.of( + context, + listen: false, + ); + await processor.processFirstRechargePurchase( + context: context, + rechargeAmountCents: package.rechargeAmountCents, + productPackage: package.productPackage, + ); + } + + @override + State createState() => _FirstRechargeDialogState(); +} + +class _FirstRechargeDialogState extends State { + late int _selectedPackageIndex; + + @override + void initState() { + super.initState(); + _selectedPackageIndex = widget.data.initialPackageIndex; + } + + @override + void didUpdateWidget(covariant FirstRechargeDialog oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.data != widget.data) { + _selectedPackageIndex = _clampedPackageIndex(_selectedPackageIndex); + } + } + + @override + Widget build(BuildContext context) { + if (widget.data.packages.isEmpty) { + return const SizedBox.shrink(); + } + + final screenWidth = MediaQuery.sizeOf(context).width; + final dialogWidth = math.min( + screenWidth - 35.w, + FirstRechargeDialog._designWidth.w, + ); + + return Material( + color: Colors.transparent, + child: Transform.translate( + offset: Offset(0, 11.w), + child: SizedBox( + width: dialogWidth, + child: AspectRatio( + aspectRatio: + FirstRechargeDialog._designWidth / + FirstRechargeDialog._designHeight, + child: LayoutBuilder( + builder: (context, constraints) { + final scale = + constraints.maxWidth / FirstRechargeDialog._designWidth; + final selectedPackage = + widget.data.packages[_clampedPackageIndex( + _selectedPackageIndex, + )]; + + return Stack( + clipBehavior: Clip.none, + children: [ + _positionedImage( + asset: _FirstRechargeAssets.panelTop, + left: 0, + top: 0, + width: 340, + height: 219, + scale: scale, + ), + _positionedImage( + asset: _FirstRechargeAssets.panelMiddle, + left: 0, + top: 219, + width: 340, + height: 166, + scale: scale, + ), + _positionedImage( + asset: _FirstRechargeAssets.panelBottom, + left: 0, + top: 351, + width: 340, + height: 177, + scale: scale, + ), + _positionedImage( + asset: _FirstRechargeAssets.title, + left: 45, + top: 26, + width: 246, + height: 116, + scale: scale, + ), + Positioned( + top: 146 * scale, + left: 0, + right: 0, + child: _SingleLineText( + widget.data.timeRemainingText, + height: 12 * scale, + fontSize: 10 * scale, + fontWeight: FontWeight.w400, + color: Colors.black, + ), + ), + Positioned( + left: 50 * scale, + top: 168 * scale, + width: 240 * scale, + height: 31 * scale, + child: _PriceSelector( + packages: widget.data.packages, + selectedIndex: _clampedPackageIndex( + _selectedPackageIndex, + ), + scale: scale, + onSelected: (index) { + setState(() { + _selectedPackageIndex = index; + }); + }, + ), + ), + ..._buildRewardCards(selectedPackage.rewards, scale), + Positioned( + top: 425 * scale, + left: 0, + right: 0, + child: _SingleLineText( + selectedPackage.totalValueText, + height: 12 * scale, + fontSize: 10 * scale, + fontWeight: FontWeight.w400, + color: Colors.black, + ), + ), + Positioned( + left: 70 * scale, + top: 447 * scale, + width: 200 * scale, + height: 42 * scale, + child: SCDebounceWidget( + onTap: () => widget.onBuyNow?.call(selectedPackage), + child: Image.asset( + _FirstRechargeAssets.buyNowButton, + fit: BoxFit.fill, + ), + ), + ), + if (selectedPackage.discountText.trim().isNotEmpty) + Positioned( + left: 238 * scale, + top: 435 * scale, + width: 38 * scale, + height: 38 * scale, + child: _DiscountBadge( + text: selectedPackage.discountText, + scale: scale, + ), + ), + Positioned( + left: 294 * scale, + top: 14 * scale, + width: 44 * scale, + height: 44 * scale, + child: SCDebounceWidget( + onTap: + () => SmartDialog.dismiss( + tag: FirstRechargeDialog.dialogTag, + ), + child: Center( + child: Image.asset( + _FirstRechargeAssets.close, + width: 24 * scale, + height: 25 * scale, + fit: BoxFit.fill, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + } + + int _clampedPackageIndex(int value) { + return value.clamp(0, widget.data.packages.length - 1).toInt(); + } + + List _buildRewardCards( + List rewards, + double scale, + ) { + const positions = [ + Offset(20, 211), + Offset(124, 211), + Offset(227, 211), + Offset(20, 316), + Offset(124, 316), + Offset(227, 316), + ]; + + return List.generate(positions.length, (index) { + final reward = + index < rewards.length + ? rewards[index] + : const FirstRechargeReward( + title: '', + style: FirstRechargeRewardStyle.prop, + ); + final position = positions[index]; + return Positioned( + left: position.dx * scale, + top: position.dy * scale, + width: 93 * scale, + height: 97 * scale, + child: _RewardCard(reward: reward, scale: scale), + ); + }); + } + + Widget _positionedImage({ + required String asset, + required double left, + required double top, + required double width, + required double height, + required double scale, + }) { + return Positioned( + left: left * scale, + top: top * scale, + width: width * scale, + height: height * scale, + child: Image.asset(asset, fit: BoxFit.fill), + ); + } +} + +class _PriceSelector extends StatelessWidget { + const _PriceSelector({ + required this.packages, + required this.selectedIndex, + required this.scale, + required this.onSelected, + }); + + final List packages; + final int selectedIndex; + final double scale; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final visiblePackages = packages.take(2).toList(); + final normalizedSelectedIndex = selectedIndex.clamp(0, 1).toInt(); + final selectedIsTrailing = normalizedSelectedIndex == 1; + + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + _FirstRechargeAssets.priceTabsBg, + fit: BoxFit.fill, + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + left: (selectedIsTrailing ? 120 : 5) * scale, + top: 2 * scale, + width: (selectedIsTrailing ? 120 : 110) * scale, + height: 27 * scale, + child: _PriceSelectedBackground( + scale: scale, + trailing: selectedIsTrailing, + ), + ), + for (var index = 0; index < visiblePackages.length; index++) + Positioned( + left: (index == 0 ? 5 : 125) * scale, + top: 7 * scale, + width: 110 * scale, + height: 16 * scale, + child: SCDebounceWidget( + onTap: () => onSelected(index), + child: _SingleLineText( + visiblePackages[index].priceText, + height: 16 * scale, + fontSize: 14 * scale, + fontWeight: FontWeight.w500, + color: + selectedIndex == index + ? Colors.white + : const Color(0xFF6F4C2B), + ), + ), + ), + ], + ); + } +} + +class _PriceSelectedBackground extends StatelessWidget { + const _PriceSelectedBackground({required this.scale, required this.trailing}); + + final double scale; + final bool trailing; + + @override + Widget build(BuildContext context) { + final texture = SizedBox( + width: 188.25 * scale, + height: 39.52 * scale, + child: Image.asset( + _FirstRechargeAssets.priceSelectedTexture, + fit: BoxFit.fill, + ), + ); + + return ClipPath( + clipper: _PriceSelectionClipper(trailing: trailing), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: trailing ? null : -19.58 * scale, + right: trailing ? -19.58 * scale : null, + top: -5.37 * scale, + child: + trailing + ? Transform( + alignment: Alignment.center, + transform: Matrix4.diagonal3Values(-1.0, 1.0, 1.0), + child: texture, + ) + : texture, + ), + ], + ), + ); + } +} + +class _PriceSelectionClipper extends CustomClipper { + const _PriceSelectionClipper({required this.trailing}); + + final bool trailing; + + @override + Path getClip(Size size) { + final w = size.width; + final h = size.height; + final curveX = w * (11.2769 / 110); + final centerY = h * (13.3043 / 27); + + if (trailing) { + return Path() + ..moveTo(0, h) + ..lineTo(0, 0) + ..lineTo(w - curveX, 0) + ..cubicTo( + w - w * (7.51795 / 110), + h * (0.521738 / 27), + w, + h * (3.91303 / 27), + w, + centerY, + ) + ..cubicTo( + w, + h * (22.6956 / 27), + w - w * (7.51795 / 110), + h * (26.3478 / 27), + w - curveX, + h, + ) + ..lineTo(0, h) + ..close(); + } + + return Path() + ..moveTo(w, h) + ..lineTo(w, 0) + ..lineTo(curveX, 0) + ..cubicTo( + w * (7.51795 / 110), + h * (0.521738 / 27), + 0, + h * (3.91303 / 27), + 0, + centerY, + ) + ..cubicTo( + 0, + h * (22.6956 / 27), + w * (7.51795 / 110), + h * (26.3478 / 27), + curveX, + h, + ) + ..lineTo(w, h) + ..close(); + } + + @override + bool shouldReclip(covariant _PriceSelectionClipper oldClipper) { + return oldClipper.trailing != trailing; + } +} + +class _RewardCard extends StatelessWidget { + const _RewardCard({required this.reward, required this.scale}); + + final FirstRechargeReward reward; + final double scale; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + reward.isCoinReward + ? _FirstRechargeAssets.rewardCardCoin + : _FirstRechargeAssets.rewardCard, + fit: BoxFit.fill, + ), + ), + if (!reward.isCoinReward && reward.badgeText.trim().isNotEmpty) + Positioned( + left: 8 * scale, + top: 3 * scale, + width: 42 * scale, + height: 10 * scale, + child: _SingleLineText( + reward.badgeText, + height: 10 * scale, + fontSize: 8 * scale, + fontWeight: FontWeight.w400, + color: Colors.white, + alignment: Alignment.centerLeft, + ), + ), + Positioned( + left: 17 * scale, + top: 14 * scale, + width: 59 * scale, + height: 59 * scale, + child: _RewardCover(reward: reward, scale: scale), + ), + Positioned( + left: 5 * scale, + right: 5 * scale, + top: 75 * scale, + height: 15 * scale, + child: _SingleLineText( + reward.title, + height: 15 * scale, + fontSize: 12 * scale, + fontWeight: FontWeight.w400, + color: const Color(0xFF6F4C2B), + ), + ), + ], + ); + } +} + +class _RewardCover extends StatelessWidget { + const _RewardCover({required this.reward, required this.scale}); + + final FirstRechargeReward reward; + final double scale; + + @override + Widget build(BuildContext context) { + final cover = reward.cover.trim(); + if (cover.startsWith('http://') || cover.startsWith('https://')) { + return CachedNetworkImage( + imageUrl: cover, + fit: BoxFit.contain, + placeholder: (_, __) => _placeholder(), + errorWidget: (_, __, ___) => _placeholder(), + ); + } + + if (cover.isNotEmpty) { + return Image.asset( + cover, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => _placeholder(), + ); + } + + return _placeholder(); + } + + Widget _placeholder() { + if (reward.isCoinReward) { + return Image.asset( + _FirstRechargeAssets.yumiCoin, + width: 42 * scale, + height: 42 * scale, + fit: BoxFit.contain, + ); + } + + return Container( + width: 48 * scale, + height: 48 * scale, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFE5A8), Color(0xFFFFB04F)], + ), + ), + child: Icon( + Icons.auto_awesome_rounded, + size: 28 * scale, + color: const Color(0xFF6F4C2B), + ), + ); + } +} + +class _DiscountBadge extends StatelessWidget { + const _DiscountBadge({required this.text, required this.scale}); + + final String text; + final double scale; + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Image.asset( + _FirstRechargeAssets.discountBadge, + fit: BoxFit.fill, + ), + ), + Positioned( + left: 5 * scale, + right: 5 * scale, + top: 8 * scale, + height: 11 * scale, + child: _SingleLineText( + text, + height: 11 * scale, + fontSize: 10 * scale, + fontWeight: FontWeight.w700, + color: const Color(0xFFFCF0B7), + shadows: [ + Shadow( + color: const Color(0xFF01378F).withValues(alpha: 0.89), + offset: Offset(0, 1 * scale), + blurRadius: 1 * scale, + ), + ], + ), + ), + Positioned( + left: 10 * scale, + right: 10 * scale, + top: 20 * scale, + height: 9 * scale, + child: _SingleLineText( + 'off', + height: 9 * scale, + fontSize: 8 * scale, + fontWeight: FontWeight.w500, + color: const Color(0xFFFCF0B7), + shadows: [ + Shadow( + color: const Color(0xFF01378F).withValues(alpha: 0.89), + offset: Offset(0, 1 * scale), + blurRadius: 1 * scale, + ), + ], + ), + ), + ], + ); + } +} + +class _SingleLineText extends StatelessWidget { + const _SingleLineText( + this.text, { + required this.height, + required this.fontSize, + required this.fontWeight, + required this.color, + this.alignment = Alignment.center, + this.shadows, + }); + + final String text; + final double height; + final double fontSize; + final FontWeight fontWeight; + final Color color; + final Alignment alignment; + final List? shadows; + + @override + Widget build(BuildContext context) { + return Align( + alignment: alignment, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: alignment, + child: SizedBox( + height: height, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.visible, + textAlign: + alignment == Alignment.centerLeft + ? TextAlign.left + : TextAlign.center, + style: TextStyle( + height: 1, + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + letterSpacing: 0, + shadows: shadows, + ), + ), + ), + ), + ); + } +} + +class _FirstRechargeAssets { + static const String panelTop = 'sc_images/first_recharge/panel_top.png'; + static const String panelMiddle = 'sc_images/first_recharge/panel_middle.png'; + static const String panelBottom = 'sc_images/first_recharge/panel_bottom.png'; + static const String close = 'sc_images/first_recharge/close.png'; + static const String title = 'sc_images/first_recharge/title.png'; + static const String priceTabsBg = + 'sc_images/first_recharge/price_tabs_bg.png'; + static const String priceSelectedTexture = + 'sc_images/first_recharge/price_selected_texture.png'; + static const String rewardCard = 'sc_images/first_recharge/reward_card.png'; + static const String rewardCardCoin = + 'sc_images/first_recharge/reward_card_coin.png'; + static const String rewardAvatarFrame = + 'sc_images/first_recharge/reward_avatar_frame_1.png'; + static const String rewardVehicle = + 'sc_images/first_recharge/reward_vehicle.png'; + static const String rewardFrame = 'sc_images/first_recharge/reward_frame.png'; + static const String rewardCoinStack = + 'sc_images/first_recharge/reward_coin_stack.png'; + static const String rewardGift = 'sc_images/first_recharge/reward_gift.png'; + static const String buyNowButton = + 'sc_images/first_recharge/buy_now_button.png'; + static const String discountBadge = + 'sc_images/first_recharge/discount_badge.png'; + static const String yumiCoin = 'sc_images/register_reward/yumi_coin.png'; +} diff --git a/lib/ui_kit/widgets/gift/sc_gift_combo_send_button.dart b/lib/ui_kit/widgets/gift/sc_gift_combo_send_button.dart index 7fbd2f5..7659409 100644 --- a/lib/ui_kit/widgets/gift/sc_gift_combo_send_button.dart +++ b/lib/ui_kit/widgets/gift/sc_gift_combo_send_button.dart @@ -66,6 +66,7 @@ class _SCGiftComboSendButtonState extends State { return SizedBox( width: widget.width, + height: 32.w, child: ClipRRect( borderRadius: borderRadius, child: DecoratedBox( @@ -99,19 +100,21 @@ class _SCGiftComboSendButtonState extends State { ), ), ), - Padding( - padding: EdgeInsets.symmetric( - vertical: 8.w, - horizontal: 20.w, - ), - child: Text( - widget.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.sp, - color: Colors.white, - fontWeight: FontWeight.w400, + Positioned.fill( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Center( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + fontWeight: FontWeight.w400, + ), + ), ), ), ), diff --git a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart index 1151814..39e4f10 100644 --- a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart +++ b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart @@ -236,6 +236,7 @@ class _RoomAnimationQueueScreenState extends State { } bool get _effectsEnabled => + SCGlobalConfig.isEntryVehicleAnimation && Provider.of( context, listen: false, @@ -256,7 +257,9 @@ class _RoomAnimationQueueScreenState extends State { @override Widget build(BuildContext context) { final effectsEnabled = context.select( - (rtcProvider) => rtcProvider.shouldShowRoomVisualEffects, + (rtcProvider) => + SCGlobalConfig.isEntryVehicleAnimation && + rtcProvider.shouldShowRoomVisualEffects, ); if (!effectsEnabled) { if (_animationQueue.isNotEmpty || _isQueueProcessing) { 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 index 2a061fd..c28a038 100644 --- a/lib/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart +++ b/lib/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart @@ -14,10 +14,17 @@ enum RoomCpInviteDialogStyle { waiting, incoming } enum RoomCpInviteActionState { pending, expired, rejected, accepted } class RoomCpInviteDialogUser { - const RoomCpInviteDialogUser({required this.name, this.avatarUrl = ''}); + const RoomCpInviteDialogUser({ + required this.name, + this.avatarUrl = '', + this.headdress = '', + this.headdressCover = '', + }); final String name; final String avatarUrl; + final String headdress; + final String headdressCover; } class RoomCpInviteDialog extends StatefulWidget { @@ -391,7 +398,6 @@ class _RoomCpInviteDialogState extends State { width: 108.w, height: 34.w, color: Colors.white, - leadingPadding: 22.w, onTap: _handleAccept, ), ], @@ -540,7 +546,7 @@ class _PositionedAvatar extends StatelessWidget { Positioned( left: avatarLeft, top: avatarTop, - child: ClipOval(child: _AvatarImage(user: user)), + child: _AvatarImage(user: user), ), Positioned( left: left, @@ -565,20 +571,13 @@ class _AvatarImage extends StatelessWidget { @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', + return head( + url: user.avatarUrl, width: 55.w, height: 55.w, - fit: BoxFit.cover, + headdress: user.headdress, + headdressCover: user.headdressCover, + showDefault: true, ); } } @@ -729,7 +728,6 @@ class _ActionButton extends StatelessWidget { required this.height, required this.color, required this.onTap, - this.leadingPadding = 0, }); final String asset; @@ -738,7 +736,6 @@ class _ActionButton extends StatelessWidget { final double height; final Color color; final VoidCallback onTap; - final double leadingPadding; @override Widget build(BuildContext context) { @@ -751,18 +748,20 @@ class _ActionButton extends StatelessWidget { 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, + Positioned.fill( + child: Center( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: color, + fontSize: 12.sp, + fontWeight: FontWeight.w700, + height: 1, + decoration: TextDecoration.none, + ), ), ), ), diff --git a/lib/ui_kit/widgets/room/empty_mai_select.dart b/lib/ui_kit/widgets/room/empty_mai_select.dart index 73ec623..1b192f4 100644 --- a/lib/ui_kit/widgets/room/empty_mai_select.dart +++ b/lib/ui_kit/widgets/room/empty_mai_select.dart @@ -1,6 +1,5 @@ import 'dart:ui' as ui; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/app_localizations.dart'; @@ -22,18 +21,17 @@ class EmptyMaiSelect extends StatelessWidget { final num index; final SocialChatUserProfile? clickUser; - const EmptyMaiSelect({Key? key, required this.index, this.clickUser}) - : super(key: key); + const EmptyMaiSelect({super.key, required this.index, this.clickUser}); @override Widget build(BuildContext context) { RtcProvider provider = Provider.of(context, listen: false); + final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id; List list = []; list.add(SizedBox(height: 10.w)); if (index == -1) { if (provider.isFz() || provider.isGL()) { - if (clickUser?.id != - AccountStorage().getCurrentUser()?.userProfile?.id) { + if (clickUser?.id != currentUserId) { list.add( _item( SCAppLocalizations.of(context)!.inviteToTheMicrophone, @@ -71,8 +69,7 @@ class EmptyMaiSelect extends StatelessWidget { } } else { MicRes? userWheat = provider.micAtIndexForDisplay(index); - if (userWheat?.user?.id == - AccountStorage().getCurrentUser()?.userProfile?.id) { + if (userWheat?.user?.id == currentUserId) { if (provider.isFz()) { ///点击的是自己 if (userWheat?.micMute ?? false) { @@ -213,6 +210,28 @@ class EmptyMaiSelect extends StatelessWidget { ); } } + final targetUserId = userWheat?.user?.id ?? clickUser?.id; + final isTargetRoomManager = + clickUser?.roles == SCRoomRolesType.HOMEOWNER.name || + clickUser?.roles == SCRoomRolesType.ADMIN.name; + final canKickOffMic = + targetUserId != null && + targetUserId.isNotEmpty && + targetUserId != currentUserId && + (provider.isFz() || (provider.isGL() && !isTargetRoomManager)); + if (canKickOffMic) { + list.add( + _item( + SCAppLocalizations.of(context)!.removeTheMic, + "sc_images/room/sc_icon_mic_leavel.png", + context, + () { + Navigator.of(context).pop(); + provider.killXiaMai(targetUserId); + }, + ), + ); + } } else { if (userWheat?.user == null) { if (!(userWheat?.micLock ?? false)) { @@ -247,7 +266,7 @@ class EmptyMaiSelect extends StatelessWidget { context, () { Navigator.of(context).pop(); - showBottomInBottomDialog( + showBottomInBottomDialog( context, RoomUserInfoCard(userId: clickUser?.id), ); @@ -271,7 +290,7 @@ class EmptyMaiSelect extends StatelessWidget { filter: ui.ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: Container( width: ScreenUtil().screenWidth, - color: Color(0xff09372E).withOpacity(0.5), + color: Color(0xff09372E).withValues(alpha: 0.5), child: Column(children: list), ), ), @@ -296,7 +315,7 @@ class EmptyMaiSelect extends StatelessWidget { decoration: msg == SCAppLocalizations.of(context)!.cancel ? BoxDecoration( - color: Color(0xff18F2B1).withOpacity(0.1), + color: Color(0xff18F2B1).withValues(alpha: 0.1), borderRadius: BorderRadius.all(Radius.circular(32.w)), ) : null, diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart index c45428c..3ec368b 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:yumi/app_localizations.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/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; @@ -47,6 +48,22 @@ class RoomRedPacketOpenDialog extends StatefulWidget { List dataList, { String? initialPacketId, }) { + if (SCRoomTopLayerGuard().isActive) { + final deferredContext = context; + final deferredDataList = List.from(dataList); + final deferredInitialPacketId = initialPacketId; + SCRoomTopLayerGuard().runWhenInactive(() { + if (!deferredContext.mounted) { + return; + } + showList( + deferredContext, + deferredDataList, + initialPacketId: deferredInitialPacketId, + ); + }); + return; + } final normalizedList = dataList .map(withLocalClaimState) .where((item) => item.hasPacketId) @@ -82,10 +99,14 @@ class RoomRedPacketOpenDialog extends StatefulWidget { maskColor: Colors.black.withValues(alpha: 0.55), clickMaskDismiss: true, builder: - (_) => RoomRedPacketOpenDialog( - data: packets[initialIndex], - dataList: packets, - initialIndex: initialIndex, + (_) => SCRoomTopLayerSuppressor( + reason: 'room_red_packet_open_dialog', + keepDialogTags: const {RoomRedPacketOpenDialog.dialogTag}, + child: RoomRedPacketOpenDialog( + data: packets[initialIndex], + dataList: packets, + initialIndex: initialIndex, + ), ), ); } diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart index afe018c..bfa3d2d 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart @@ -24,6 +24,7 @@ import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.d import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; @@ -54,7 +55,11 @@ class RoomRedPacketSendPanel extends StatefulWidget { animationType: SmartAnimationType.fade, maskColor: Colors.transparent, clickMaskDismiss: true, - builder: (_) => const RoomRedPacketSendPanel(), + builder: + (_) => const SCRoomTopLayerSuppressor( + reason: 'room_red_packet_send_panel', + child: RoomRedPacketSendPanel(), + ), ); } @@ -841,7 +846,11 @@ class _RoomRedPacketRechargeSheet extends StatefulWidget { animationType: SmartAnimationType.fade, maskColor: Colors.black.withValues(alpha: 0.45), clickMaskDismiss: true, - builder: (_) => const _RoomRedPacketRechargeSheet(), + builder: + (_) => const SCRoomTopLayerSuppressor( + reason: 'room_red_packet_recharge_sheet', + child: _RoomRedPacketRechargeSheet(), + ), ); } 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 index 7381b96..7a4f0fa 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart @@ -12,6 +12,7 @@ import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; @@ -103,11 +104,14 @@ class RoomRocketFloatingEntry extends StatelessWidget { maskColor: Colors.transparent, clickMaskDismiss: false, builder: - (_) => _RoomRocketDialogLoader( - roomId: roomId, - navigator: navigator, - initialStatus: status, - onClose: () => SmartDialog.dismiss(tag: dialogTag), + (_) => SCRoomTopLayerSuppressor( + reason: 'room_rocket_dialog', + child: _RoomRocketDialogLoader( + roomId: roomId, + navigator: navigator, + initialStatus: status, + onClose: () => SmartDialog.dismiss(tag: dialogTag), + ), ), ); } @@ -857,12 +861,15 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { maskColor: Colors.transparent, clickMaskDismiss: false, builder: - (_) => _RoomRocketRecordDialogLoader( - roomId: widget.roomId, - onClose: - () => SmartDialog.dismiss( - tag: RoomRocketFloatingEntry.recordDialogTag, - ), + (_) => SCRoomTopLayerSuppressor( + reason: 'room_rocket_record_dialog', + child: _RoomRocketRecordDialogLoader( + roomId: widget.roomId, + onClose: + () => SmartDialog.dismiss( + tag: RoomRocketFloatingEntry.recordDialogTag, + ), + ), ), ); }); @@ -879,12 +886,15 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { maskColor: Colors.transparent, clickMaskDismiss: false, builder: - (_) => _RoomRocketNoteDialogLoader( - initialSections: _noteSections, - onClose: - () => SmartDialog.dismiss( - tag: RoomRocketFloatingEntry.noteDialogTag, - ), + (_) => SCRoomTopLayerSuppressor( + reason: 'room_rocket_note_dialog', + child: _RoomRocketNoteDialogLoader( + initialSections: _noteSections, + onClose: + () => SmartDialog.dismiss( + tag: RoomRocketFloatingEntry.noteDialogTag, + ), + ), ), ); }); diff --git a/lib/ui_kit/widgets/room/room_animation_switch_dialog.dart b/lib/ui_kit/widgets/room/room_animation_switch_dialog.dart new file mode 100644 index 0000000..c7a7da9 --- /dev/null +++ b/lib/ui_kit/widgets/room/room_animation_switch_dialog.dart @@ -0,0 +1,205 @@ +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/app/constants/sc_global_config.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/components/text/sc_text.dart'; + +class RoomAnimationSwitchDialog extends StatefulWidget { + const RoomAnimationSwitchDialog({super.key}); + + static const String tag = "showRoomAnimationSwitchDialog"; + + static void show(BuildContext context) { + SmartDialog.show( + tag: tag, + alignment: Alignment.bottomCenter, + animationType: SmartAnimationType.fade, + debounce: true, + clickMaskDismiss: true, + maskColor: Colors.transparent, + builder: + (_) => const SCRoomTopLayerSuppressor( + reason: 'room_animation_switch_dialog', + child: RoomAnimationSwitchDialog(), + ), + ); + } + + @override + State createState() => + _RoomAnimationSwitchDialogState(); +} + +class _RoomAnimationSwitchDialogState extends State { + late bool _giftEffectsEnabled; + late bool _entryEffectsEnabled; + + String? get _account => + AccountStorage().getCurrentUser()?.userProfile?.account; + + @override + void initState() { + super.initState(); + _giftEffectsEnabled = SCGlobalConfig.isGiftSpecialEffects; + _entryEffectsEnabled = SCGlobalConfig.isEntryVehicleAnimation; + } + + Future _setGiftEffectsEnabled(bool enabled) async { + final next = SCGlobalConfig.clampVisualEffectPreference(enabled); + setState(() { + _giftEffectsEnabled = next; + }); + SCGlobalConfig.isGiftSpecialEffects = next; + SCGlobalConfig.isLuckGiftSpecialEffects = next; + await Future.wait([ + DataPersistence.setBool("$_account-GiftSpecialEffects", next), + DataPersistence.setBool("$_account-LuckGiftSpecialEffects", next), + ]); + } + + Future _setEntryEffectsEnabled(bool enabled) async { + final next = SCGlobalConfig.clampVisualEffectPreference(enabled); + setState(() { + _entryEffectsEnabled = next; + }); + SCGlobalConfig.isEntryVehicleAnimation = next; + Provider.of( + context, + listen: false, + ).notifyRoomVisualEffectPreferenceChanged(); + await DataPersistence.setBool("$_account-EntryVehicleAnimation", next); + } + + @override + Widget build(BuildContext context) { + return Container( + width: ScreenUtil().screenWidth, + height: 151.w, + decoration: BoxDecoration( + color: const Color(0xff072121), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12.w), + topRight: Radius.circular(12.w), + ), + ), + child: Stack( + children: [ + Positioned( + top: 16.w, + left: 0, + right: 0, + child: text( + "Animation", + fontSize: 18, + textColor: Colors.white, + fontWeight: FontWeight.w500, + lineHeight: 1, + textAlign: TextAlign.center, + ), + ), + _buildSwitchRow( + top: 59.w, + title: "Gift Animation", + value: _giftEffectsEnabled, + onTap: () => _setGiftEffectsEnabled(!_giftEffectsEnabled), + ), + _buildSwitchRow( + top: 97.w, + title: "Vehicle Animation", + value: _entryEffectsEnabled, + onTap: () => _setEntryEffectsEnabled(!_entryEffectsEnabled), + ), + ], + ), + ); + } + + Widget _buildSwitchRow({ + required double top, + required String title, + required bool value, + required VoidCallback onTap, + }) { + return Positioned( + top: top - 8.w, + left: 0, + right: 0, + height: 30.w, + child: Stack( + children: [ + Positioned( + left: 36.w, + top: 8.w, + height: 14.w, + child: Align( + alignment: Alignment.centerLeft, + child: text( + title, + fontSize: 14, + textColor: Colors.white, + fontWeight: FontWeight.w400, + lineHeight: 1, + ), + ), + ), + SCDebounceWidget( + onTap: onTap, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.only(right: 24.w), + child: SizedBox( + width: 56.w, + height: 30.w, + child: Center(child: _FigmaAnimationSwitch(value: value)), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _FigmaAnimationSwitch extends StatelessWidget { + const _FigmaAnimationSwitch({required this.value}); + + final bool value; + + @override + Widget build(BuildContext context) { + final color = value ? const Color(0xff18F2B1) : const Color(0xff999999); + return SizedBox( + width: 32.w, + height: 14.w, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7.w), + border: Border.all(color: color, width: 1.w), + ), + ), + ), + Positioned( + left: value ? 20.w : 2.w, + top: 2.w, + child: Container( + width: 10.w, + height: 10.w, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/room_banner_view.dart b/lib/ui_kit/widgets/room/room_banner_view.dart index 87358b3..624f482 100644 --- a/lib/ui_kit/widgets/room/room_banner_view.dart +++ b/lib/ui_kit/widgets/room/room_banner_view.dart @@ -1,89 +1,94 @@ -import 'package:carousel_slider/carousel_slider.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/shared/tools/sc_banner_utils.dart'; -import 'package:provider/provider.dart'; -import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/services/general/sc_app_general_manager.dart'; - -class RoomBannerView extends StatefulWidget { - @override - _RoomBannerViewState createState() => _RoomBannerViewState(); -} - -class _RoomBannerViewState extends State { - int _currentIndex = 0; - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, ref, child) { - return _banner(ref); - }, - ); - } - - _banner(SCAppGeneralManager ref) { - return ref.roomBanners.isEmpty - ? Container() - : SizedBox( - width: 46.w, - child: Column( - children: [ - CarouselSlider( - options: CarouselOptions( - height: 46.w, - autoPlay: true, - // 启用自动播放 - enlargeCenterPage: false, - // 居中放大当前页面 - aspectRatio: 1 / 1, - // 宽高比 - enableInfiniteScroll: true, - // 启用无限循环 - autoPlayAnimationDuration: Duration(milliseconds: 800), - // 自动播放动画时长 - viewportFraction: 1, - // 视口分数 - onPageChanged: (index, reason) { - _currentIndex = index; - setState(() {}); - }, - ), - items: - ref.roomBanners.map((item) { - return GestureDetector( - child: netImage(url: item.smallCover ?? ""), - onTap: () { - SCBannerUtils.openBanner(item, context); - }, - ); - }).toList(), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: - ref.roomBanners.asMap().entries.map((entry) { - return Container( - width: 4.0, - height: 4.0, - margin: EdgeInsetsDirectional.fromSTEB(2, 3, 2, 0), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - _currentIndex == entry.key - ? Colors.blue - : Colors.white, - ), - ); - }).toList(), - ), - ), - ], - ), - ); - } -} +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/shared/tools/sc_banner_utils.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; +import 'package:yumi/services/general/sc_app_general_manager.dart'; + +class RoomBannerView extends StatefulWidget { + const RoomBannerView({super.key}); + + @override + State createState() => _RoomBannerViewState(); +} + +class _RoomBannerViewState extends State { + int _currentIndex = 0; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, ref, child) { + return _banner(ref); + }, + ); + } + + _banner(SCAppGeneralManager ref) { + return ref.roomBanners.isEmpty + ? Container() + : SizedBox( + width: 46.w, + child: Column( + children: [ + CarouselSlider( + options: CarouselOptions( + height: 46.w, + autoPlay: true, + // 启用自动播放 + enlargeCenterPage: false, + // 居中放大当前页面 + aspectRatio: 1 / 1, + // 宽高比 + enableInfiniteScroll: true, + // 启用无限循环 + autoPlayAnimationDuration: Duration(milliseconds: 800), + // 自动播放动画时长 + viewportFraction: 1, + // 视口分数 + onPageChanged: (index, reason) { + _currentIndex = index; + setState(() {}); + }, + ), + items: + ref.roomBanners.map((item) { + return GestureDetector( + child: netImage(url: item.smallCover ?? ""), + onTap: () { + SCBannerUtils.openBanner( + item, + context, + openH5AsRoomPopup: true, + ); + }, + ); + }).toList(), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: + ref.roomBanners.asMap().entries.map((entry) { + return Container( + width: 4.0, + height: 4.0, + margin: EdgeInsetsDirectional.fromSTEB(2, 3, 2, 0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + _currentIndex == entry.key + ? Colors.blue + : Colors.white, + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/room_bottom_widget.dart b/lib/ui_kit/widgets/room/room_bottom_widget.dart index c1a1b72..a04f178 100644 --- a/lib/ui_kit/widgets/room/room_bottom_widget.dart +++ b/lib/ui_kit/widgets/room/room_bottom_widget.dart @@ -14,6 +14,7 @@ import 'package:yumi/ui_kit/widgets/room/room_menu_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import '../../../app/routes/sc_fluro_navigator.dart'; @@ -238,7 +239,10 @@ class _RoomBottomWidgetState extends State { animationType: SmartAnimationType.fade, clickMaskDismiss: true, builder: (_) { - return GiftPage(); + return SCRoomTopLayerSuppressor( + reason: 'room_gift_panel', + child: GiftPage(), + ); }, ); } @@ -254,9 +258,12 @@ class _RoomBottomWidgetState extends State { maskColor: Colors.transparent, clickMaskDismiss: true, builder: (_) { - return RoomMenuDialog(roomMenuStime1, (eTime) { - roomMenuStime1 = eTime; - }); + return SCRoomTopLayerSuppressor( + reason: 'room_menu_dialog', + child: RoomMenuDialog(roomMenuStime1, (eTime) { + roomMenuStime1 = eTime; + }), + ); }, ); }, diff --git a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart index 4ce7bbd..e28ac8a 100644 --- a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart +++ b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/modules/room_game/views/room_game_list_sheet.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; @@ -12,11 +15,20 @@ class RoomGameEntryButton extends StatelessWidget { Widget build(BuildContext context) { return SCDebounceWidget( onTap: () { - showBottomInBottomDialog( - context, - RoomGameBottomSheet(roomContext: context), - barrierColor: Colors.black54, + final guardLease = SCRoomTopLayerGuard().acquire( + reason: 'room_game_list_sheet', ); + try { + unawaited( + showBottomInBottomDialog( + context, + RoomGameBottomSheet(roomContext: context), + barrierColor: Colors.black54, + ).whenComplete(guardLease.release), + ); + } catch (_) { + guardLease.release(); + } }, child: SCSvgaAssetWidget( assetPath: "sc_images/room/sc_icon_room_game_entry_anim.svga", diff --git a/lib/ui_kit/widgets/room/room_head_widget.dart b/lib/ui_kit/widgets/room/room_head_widget.dart index 3539d48..3176290 100644 --- a/lib/ui_kit/widgets/room/room_head_widget.dart +++ b/lib/ui_kit/widgets/room/room_head_widget.dart @@ -6,6 +6,7 @@ import 'package:marquee/marquee.dart'; import 'package:provider/provider.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/modules/room/detail/room_detail_page.dart'; @@ -223,7 +224,10 @@ class _RoomHeadWidgetState extends State { animationType: SmartAnimationType.fade, maskColor: Colors.black54, builder: (_) { - return SCEditRoomAnnouncementPage(); + return SCRoomTopLayerSuppressor( + reason: 'room_announcement_dialog', + child: SCEditRoomAnnouncementPage(), + ); }, ); } diff --git a/lib/ui_kit/widgets/room/room_menu_dialog.dart b/lib/ui_kit/widgets/room/room_menu_dialog.dart index 8760d88..ec83661 100644 --- a/lib/ui_kit/widgets/room/room_menu_dialog.dart +++ b/lib/ui_kit/widgets/room/room_menu_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:yumi/ui_kit/widgets/room/switch_model/room_mic_switch_page.dart'; import 'package:yumi/main.dart'; @@ -13,6 +14,7 @@ import 'package:yumi/shared/tools/sc_pick_utils.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/ui_kit/widgets/room/room_animation_switch_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart'; import '../../../app/routes/sc_fluro_navigator.dart'; import '../../../modules/index/main_route.dart'; @@ -34,7 +36,7 @@ class _RoomMenuDialogState extends State { static const int _menuMicManagement = 0; static const int _menuClearMessage = 1; static const int _menuPicture = 2; - static const int _menuSettings = 3; + static const int _menuAnimationEffects = 3; static const int _menuSound = 4; static const int _menuReport = 5; static const int _menuBackground = 6; @@ -103,15 +105,13 @@ class _RoomMenuDialogState extends State { "red_packet/small_packet_icon.png", ), ); - if (Provider.of(context, listen: false).isFz()) { - items2.add( - RoomMenu( - _menuSettings, - SCAppLocalizations.of(context)!.settings, - "sc_icon_room_menu_settins.png", - ), - ); - } + items2.add( + RoomMenu( + _menuAnimationEffects, + SCAppLocalizations.of(context)!.specialEffectsManagement, + "sc_icon_room_animation_effects_menu.png", + ), + ); items2.add( RoomMenu( _menuSound, @@ -236,7 +236,10 @@ class _RoomMenuDialogState extends State { animationType: SmartAnimationType.fade, debounce: true, builder: (_) { - return RoomMicSwitchPage(); + return SCRoomTopLayerSuppressor( + reason: 'room_mic_switch_dialog', + child: RoomMicSwitchPage(), + ); }, ); } else if (item.id == _menuClearMessage) { @@ -275,12 +278,9 @@ class _RoomMenuDialogState extends State { } else if (item.id == _menuRedPacket) { SmartDialog.dismiss(tag: "showRoomMenuDialog"); RoomRedPacketSendPanel.show(navigatorKey.currentState!.context); - } else if (item.id == _menuSettings) { + } else if (item.id == _menuAnimationEffects) { SmartDialog.dismiss(tag: "showRoomMenuDialog"); - VoiceRoomRoute.openRoomEdit( - navigatorKey.currentState!.context, - needRestCurrentRoomInfo: false, - ); + RoomAnimationSwitchDialog.show(navigatorKey.currentState!.context); } else if (item.id == _menuSound) { SmartDialog.dismiss(tag: "showRoomMenuDialog"); Provider.of( @@ -306,6 +306,24 @@ class _RoomMenuDialogState extends State { } Widget _buildMenuIcon(RoomMenu item) { + if (item.id == _menuAnimationEffects) { + return Container( + width: 45.w, + height: 45.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.16), + ), + alignment: Alignment.center, + child: Image.asset( + "sc_images/room/${item.icon}", + width: 26.w, + height: 26.w, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + ); + } if (item.id != _menuMusic && item.id != _menuRedPacket) { return Image.asset( "sc_images/room/${item.icon}", diff --git a/lib/ui_kit/widgets/room/room_online_user_widget.dart b/lib/ui_kit/widgets/room/room_online_user_widget.dart index 7c84a00..eeec23a 100644 --- a/lib/ui_kit/widgets/room/room_online_user_widget.dart +++ b/lib/ui_kit/widgets/room/room_online_user_widget.dart @@ -8,6 +8,7 @@ import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/services/room/rc_room_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/modules/room/online/room_online_page.dart'; @@ -207,7 +208,10 @@ class _RoomOnlineUserWidgetState extends State { alignment: Alignment.bottomCenter, animationType: SmartAnimationType.fade, builder: (_) { - return RoomGiftRankPage(); + return SCRoomTopLayerSuppressor( + reason: 'room_gift_rank_dialog', + child: RoomGiftRankPage(), + ); }, ); }, diff --git a/lib/ui_kit/widgets/room/room_recharge_bottom_sheet.dart b/lib/ui_kit/widgets/room/room_recharge_bottom_sheet.dart new file mode 100644 index 0000000..60c004c --- /dev/null +++ b/lib/ui_kit/widgets/room/room_recharge_bottom_sheet.dart @@ -0,0 +1,529 @@ +import 'dart:io'; + +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:in_app_purchase/in_app_purchase.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/main.dart'; +import 'package:yumi/modules/wallet/recharge/recharge_page.dart'; +import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/services/payment/google_payment_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_product_config_res.dart'; +import 'package:yumi/shared/tools/sc_loading_manager.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/components/sc_tts.dart'; + +class RoomRechargeBottomSheet extends StatefulWidget { + const RoomRechargeBottomSheet({super.key}); + + static const String dialogTag = 'showRoomRechargeBottomSheet'; + + static void show(BuildContext context) { + SmartDialog.dismiss(tag: dialogTag); + SmartDialog.show( + tag: dialogTag, + alignment: Alignment.bottomCenter, + animationType: SmartAnimationType.fade, + maskColor: Colors.black.withValues(alpha: 0.6), + clickMaskDismiss: true, + builder: + (_) => const SCRoomTopLayerSuppressor( + reason: 'room_recharge_bottom_sheet', + child: RoomRechargeBottomSheet(), + ), + ); + } + + @override + State createState() => + _RoomRechargeBottomSheetState(); +} + +class _RoomRechargeBottomSheetState extends State { + static const Color _sheetColor = Color(0xFF072121); + static const Color _cardColor = Color(0xFF011414); + static const Color _accentColor = Color(0xFF18F2B1); + static const String _coinAsset = + 'sc_images/room/sc_room_recharge_modal_coin.png'; + + int _fallbackSelectedIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initializeRecharge(); + } + }); + } + + void _initializeRecharge() { + final paymentContext = navigatorKey.currentState?.context ?? context; + Provider.of( + paymentContext, + listen: false, + ).balance(); + + if (!Platform.isAndroid) { + return; + } + + final processor = Provider.of( + paymentContext, + listen: false, + ); + if (processor.products.isEmpty) { + processor.initializePaymentProcessor(paymentContext); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: ScreenUtil().screenWidth, + height: 306.w, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: _sheetColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(12.w)), + ), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: 24.w, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.vertical(top: Radius.circular(12.w)), + ), + ), + ), + PositionedDirectional( + top: 7.w, + start: 16.w, + child: Text( + 'Your Balance Is Low. Please Add Funds.', + style: TextStyle( + color: Colors.white, + fontSize: 10.sp, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ), + Positioned( + top: 36.w, + left: 0, + right: 0, + child: Center( + child: Text( + 'Payment Methods', + style: TextStyle( + color: Colors.white, + fontSize: 18.sp, + height: 1, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ), + ), + PositionedDirectional( + top: 74.w, + start: 16.w, + end: 16.w, + child: _buildPaymentMethodRow(), + ), + PositionedDirectional( + top: 126.w, + start: 16.w, + end: 16.w, + child: _buildProductOptions(), + ), + PositionedDirectional( + top: 222.w, + start: 24.w, + end: 24.w, + child: _buildConfirmButton(), + ), + ], + ), + ); + } + + Widget _buildPaymentMethodRow() { + return Container( + height: 40.w, + padding: EdgeInsets.symmetric(horizontal: 8.w), + decoration: BoxDecoration( + color: _cardColor, + borderRadius: BorderRadius.circular(8.w), + ), + child: Row( + children: [ + SizedBox( + width: 28.w, + height: 28.w, + child: Center( + child: Text( + 'G', + style: TextStyle( + color: Colors.white, + fontSize: 22.sp, + height: 1, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + ), + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + 'Google Pay', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ), + _buildSelectedRadio(), + SizedBox(width: 7.w), + ], + ), + ); + } + + Widget _buildSelectedRadio() { + return Container( + width: 16.w, + height: 16.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.w), + ), + alignment: Alignment.center, + child: Container( + width: 10.w, + height: 10.w, + decoration: const BoxDecoration( + color: _accentColor, + shape: BoxShape.circle, + ), + ), + ); + } + + Widget _buildProductOptions() { + if (!Platform.isAndroid) { + return Row( + children: [ + Expanded(child: _buildFallbackProductItem(0)), + SizedBox(width: 13.w), + Expanded(child: _buildFallbackProductItem(1)), + ], + ); + } + return Consumer( + builder: (context, processor, child) { + final products = processor.products.take(2).toList(); + if (products.isEmpty) { + return Row( + children: [ + Expanded(child: _buildFallbackProductItem(0)), + SizedBox(width: 13.w), + Expanded(child: _buildFallbackProductItem(1)), + ], + ); + } + + return Row( + children: [ + for (int index = 0; index < products.length; index++) ...[ + if (index > 0) SizedBox(width: 13.w), + Expanded( + child: _buildStoreProductItem( + processor: processor, + productConfig: products[index], + index: index, + ), + ), + ], + if (products.length == 1) ...[ + SizedBox(width: 13.w), + const Spacer(), + ], + ], + ); + }, + ); + } + + Widget _buildFallbackProductItem(int index) { + return _RechargeProductCard( + selected: index == _fallbackSelectedIndex, + amount: '150,000', + originalAmount: '100,000', + price: 'USD 4.99', + onTap: () => setState(() => _fallbackSelectedIndex = index), + ); + } + + Widget _buildStoreProductItem({ + required AndroidPaymentProcessor processor, + required SelecteProductConfig productConfig, + required int index, + }) { + final product = processor.productMap[productConfig.produc.id]; + return _RechargeProductCard( + selected: productConfig.isSelecte, + amount: _formatAmount(_totalCandy(product)), + originalAmount: _formatOriginalAmount(product), + price: productConfig.produc.price, + onTap: () => processor.chooseProductConfig(index), + ); + } + + String _formatOriginalAmount(SCProductConfigRes? product) { + if (product == null) { + return ''; + } + final obtainCandy = (product.obtainCandy ?? 0).round(); + final rewardCandy = (product.rewardCandy ?? 0).round(); + if (obtainCandy <= 0 || rewardCandy <= 0) { + return ''; + } + return _formatAmount(obtainCandy); + } + + int _totalCandy(SCProductConfigRes? product) { + if (product == null) { + return 0; + } + return ((product.obtainCandy ?? 0) + (product.rewardCandy ?? 0)).round(); + } + + String _formatAmount(int value) { + final sign = value < 0 ? '-' : ''; + final digits = value.abs().toString(); + final buffer = StringBuffer(); + for (int index = 0; index < digits.length; index++) { + final remaining = digits.length - index; + buffer.write(digits[index]); + if (remaining > 1 && remaining % 3 == 1) { + buffer.write(','); + } + } + return '$sign$buffer'; + } + + Widget _buildConfirmButton() { + return SCDebounceWidget( + onTap: _processGooglePurchase, + child: Container( + height: 44.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _accentColor, + borderRadius: BorderRadius.circular(22.w), + ), + child: Text( + 'Confirm Top-Up', + style: TextStyle( + color: Colors.black, + fontSize: 20.sp, + height: 1, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ), + ); + } + + Future _processGooglePurchase() async { + if (!Platform.isAndroid) { + SCTts.show('Google Play payment is only available on Android'); + return; + } + + final pleaseSelectMessage = + SCAppLocalizations.of(context)!.pleaseSelectaItem; + final paymentContext = navigatorKey.currentState?.context ?? context; + final processor = Provider.of( + paymentContext, + listen: false, + ); + if (processor.products.isEmpty) { + await processor.initializePaymentProcessor(paymentContext); + if (!mounted) { + return; + } + } + + final product = _selectedProduct(processor.products); + if (product == null) { + SCTts.show(pleaseSelectMessage); + return; + } + + try { + await processor.recoverTransactions(); + SCLoadingManager.show(); + final success = await processor.iap.buyConsumable( + purchaseParam: PurchaseParam(productDetails: product.produc), + autoConsume: kReleaseMode, + ); + if (!success) { + SCTts.show('Purchase failed, please check your network connection'); + } + } catch (error) { + SCTts.show('Purchase failed: $error'); + } finally { + SCLoadingManager.hide(); + } + } + + SelecteProductConfig? _selectedProduct(List products) { + for (final product in products) { + if (product.isSelecte) { + return product; + } + } + return products.isEmpty ? null : products.first; + } +} + +class _RechargeProductCard extends StatelessWidget { + const _RechargeProductCard({ + required this.selected, + required this.amount, + required this.originalAmount, + required this.price, + required this.onTap, + }); + + final bool selected; + final String amount; + final String originalAmount; + final String price; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SCDebounceWidget( + onTap: onTap, + child: Container( + height: 72.w, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: _RoomRechargeBottomSheetState._cardColor, + borderRadius: BorderRadius.circular(8.w), + border: + selected + ? Border.all( + color: _RoomRechargeBottomSheetState._accentColor, + width: 1.w, + ) + : null, + ), + child: Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + _RoomRechargeBottomSheetState._coinAsset, + width: 16.w, + height: 16.w, + fit: BoxFit.contain, + ), + SizedBox(width: 2.w), + Flexible( + child: Text( + amount, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + height: 1, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + SizedBox(height: 4.w), + Visibility( + visible: originalAmount.isNotEmpty, + maintainAnimation: true, + maintainSize: true, + maintainState: true, + child: Text( + originalAmount, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 10.sp, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.lineThrough, + decorationColor: Colors.white.withValues(alpha: 0.6), + ), + ), + ), + ], + ), + ), + Container( + height: 20.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(8.w), + ), + ), + child: Text( + price, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + height: 1, + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/room_user_card_setting.dart b/lib/ui_kit/widgets/room/room_user_card_setting.dart index 3b18a53..92b740d 100644 --- a/lib/ui_kit/widgets/room/room_user_card_setting.dart +++ b/lib/ui_kit/widgets/room/room_user_card_setting.dart @@ -1,5 +1,4 @@ import 'dart:ui' as ui; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -16,62 +15,47 @@ import 'package:yumi/services/audio/rtc_manager.dart'; import '../../../shared/data_sources/models/enum/sc_room_roles_type.dart'; class RoomUserCardSetting extends StatefulWidget { - String? roomId; - RoomUserCardRes? userCardInfo; + final String? roomId; + final RoomUserCardRes? userCardInfo; - RoomUserCardSetting({super.key, this.roomId, this.userCardInfo}); + const RoomUserCardSetting({super.key, this.roomId, this.userCardInfo}); @override - _RoomUserCardSettingState createState() => _RoomUserCardSettingState(); + State createState() => _RoomUserCardSettingState(); } class _RoomUserCardSettingState extends State { - bool isOnMai = false; - bool micMute = false; - - @override - void initState() { - super.initState(); - isOnMai = - Provider.of( - context, - listen: false, - ).userOnMaiInIndex(widget.userCardInfo?.userProfile?.id ?? "") != - -1; - } - @override Widget build(BuildContext context) { + final rtcProvider = Provider.of(context, listen: false); + final targetUserId = widget.userCardInfo?.userProfile?.id ?? ""; + final isTargetRoomManager = + widget.userCardInfo?.roomRole == SCRoomRolesType.ADMIN.name || + widget.userCardInfo?.roomRole == SCRoomRolesType.HOMEOWNER.name; + final isOnMai = rtcProvider.userOnMaiInIndex(targetUserId) != -1; List list = []; list.add(SizedBox(height: 10.w)); if (isOnMai) { - if (Provider.of(context, listen: false).isFz()) { + if (rtcProvider.isFz()) { ///操作的人是房主 list.add( _item(SCAppLocalizations.of(context)!.removeTheMic, null, () { Navigator.of(context).pop(); - Provider.of( - context, - listen: false, - ).killXiaMai(widget.userCardInfo?.userProfile?.id ?? ""); + rtcProvider.killXiaMai(targetUserId); }), ); } else { - if (widget.userCardInfo?.roomRole != SCRoomRolesType.ADMIN.name && - widget.userCardInfo?.roomRole != SCRoomRolesType.HOMEOWNER.name) { + if (rtcProvider.isGL() && !isTargetRoomManager) { list.add( _item(SCAppLocalizations.of(context)!.removeTheMic, null, () { Navigator.of(context).pop(); - Provider.of( - context, - listen: false, - ).killXiaMai(widget.userCardInfo?.userProfile?.id ?? ""); + rtcProvider.killXiaMai(targetUserId); }), ); } } } - if (Provider.of(context, listen: false).isFz()) { + if (rtcProvider.isFz()) { list.add( _item(SCAppLocalizations.of(context)!.setUpAnIdentity, null, () { Navigator.of(context).pop(); @@ -79,8 +63,7 @@ class _RoomUserCardSettingState extends State { }), ); } - if (widget.userCardInfo?.roomRole != SCRoomRolesType.ADMIN.name && - widget.userCardInfo?.roomRole != SCRoomRolesType.HOMEOWNER.name) { + if (!isTargetRoomManager) { list.add( _item(SCAppLocalizations.of(context)!.kickedOutOfRoom, null, () { Navigator.of(context).pop(); @@ -116,7 +99,7 @@ class _RoomUserCardSettingState extends State { filter: ui.ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: Container( width: ScreenUtil().screenWidth, - color: Color(0xff09372E).withOpacity(0.5), + color: Color(0xff09372E).withValues(alpha: 0.5), child: Column(children: list), ), ), @@ -134,12 +117,12 @@ class _RoomUserCardSettingState extends State { children: [ Container( decoration: - msg == SCAppLocalizations.of(context)!.cancel - ? BoxDecoration( - color: Color(0xff18F2B1).withOpacity(0.1), - borderRadius: BorderRadius.all(Radius.circular(32.w)), - ) - : null, + msg == SCAppLocalizations.of(context)!.cancel + ? BoxDecoration( + color: Color(0xff18F2B1).withValues(alpha: 0.1), + borderRadius: BorderRadius.all(Radius.circular(32.w)), + ) + : null, width: ScreenUtil().screenWidth * 0.7, padding: EdgeInsets.symmetric(vertical: 10.w), child: Row( @@ -156,7 +139,7 @@ class _RoomUserCardSettingState extends State { msg, style: TextStyle( fontSize: sp(16), - color:Colors.white, + color: Colors.white, fontWeight: FontWeight.w400, decoration: TextDecoration.none, ), diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index 0ea2192..a7c6439 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -22,6 +22,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/tools/sc_string_utils.dart'; import 'package:yumi/shared/data_sources/models/enum/sc_props_type.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; @@ -1440,7 +1441,10 @@ class _RoomUserInfoCardState extends State { animationType: SmartAnimationType.fade, clickMaskDismiss: true, builder: (_) { - return GiftPage(toUser: ref.userCardInfo?.userProfile); + return SCRoomTopLayerSuppressor( + reason: 'room_user_gift_panel', + child: GiftPage(toUser: ref.userCardInfo?.userProfile), + ); }, ); } @@ -1927,24 +1931,24 @@ class _RoomCpProgressData { return fallbackText.isEmpty ? "Lv.1 Simple Love" : fallbackText; } final levelName = level.levelName?.trim(); - if (level.level != null && levelName != null && levelName.isNotEmpty) { - return "Lv.${level.level} $levelName"; + if (levelName != null && levelName.isNotEmpty) { + return levelName; } if (level.level != null) { return "Lv.${level.level}"; } - if (levelName != null && levelName.isNotEmpty) { - return levelName; - } return fallbackText.isEmpty ? "Lv.1 Simple Love" : fallbackText; } static String _levelShortText(int? level, {String? fallback}) { + final fallbackText = fallback?.trim() ?? ""; + if (fallbackText.isNotEmpty) { + return fallbackText; + } if (level != null) { return "Lv. $level"; } - final fallbackText = fallback?.trim() ?? ""; - return fallbackText.isEmpty ? "Lv. 1" : fallbackText; + return "Lv. 1"; } static int _levelIndex(String levelText) { diff --git a/pubspec.lock b/pubspec.lock index 3e30807..1659351 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -876,7 +876,7 @@ packages: source: hosted version: "3.2.3" in_app_purchase_android: - dependency: transitive + dependency: "direct main" description: name: in_app_purchase_android sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71" diff --git a/pubspec.yaml b/pubspec.yaml index 8f20520..018e2d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,10 +99,11 @@ dependencies: path: ./local_packages/flutter_svga-0.0.13-patched #banner carousel_slider: ^5.1.1 - #谷歌支付 - in_app_purchase: ^3.2.3 - - device_info_plus: ^10.0.0 + #谷歌支付 + in_app_purchase: ^3.2.3 + in_app_purchase_android: ^0.4.0+10 + + device_info_plus: ^10.0.0 package_info_plus: ^8.0.0 uuid: ^4.5.1 wakelock_plus: ^1.1.0 @@ -167,6 +168,7 @@ flutter: - sc_images/index/ - sc_images/daily_sign_in/ - sc_images/register_reward/ + - sc_images/first_recharge/ - sc_images/room/ - sc_images/room/cp_profile/ - sc_images/room/cp_rights/ diff --git a/sc_images/first_recharge/buy_now_button.png b/sc_images/first_recharge/buy_now_button.png new file mode 100644 index 0000000..4d7b954 Binary files /dev/null and b/sc_images/first_recharge/buy_now_button.png differ diff --git a/sc_images/first_recharge/close.png b/sc_images/first_recharge/close.png new file mode 100644 index 0000000..7a179cf Binary files /dev/null and b/sc_images/first_recharge/close.png differ diff --git a/sc_images/first_recharge/discount_badge.png b/sc_images/first_recharge/discount_badge.png new file mode 100644 index 0000000..d7c4d2b Binary files /dev/null and b/sc_images/first_recharge/discount_badge.png differ diff --git a/sc_images/first_recharge/panel_bottom.png b/sc_images/first_recharge/panel_bottom.png new file mode 100644 index 0000000..ebc4a90 Binary files /dev/null and b/sc_images/first_recharge/panel_bottom.png differ diff --git a/sc_images/first_recharge/panel_middle.png b/sc_images/first_recharge/panel_middle.png new file mode 100644 index 0000000..3718362 Binary files /dev/null and b/sc_images/first_recharge/panel_middle.png differ diff --git a/sc_images/first_recharge/panel_top.png b/sc_images/first_recharge/panel_top.png new file mode 100644 index 0000000..e8f4bd6 Binary files /dev/null and b/sc_images/first_recharge/panel_top.png differ diff --git a/sc_images/first_recharge/preview_background.png b/sc_images/first_recharge/preview_background.png new file mode 100644 index 0000000..7621668 Binary files /dev/null and b/sc_images/first_recharge/preview_background.png differ diff --git a/sc_images/first_recharge/price_selected_texture.png b/sc_images/first_recharge/price_selected_texture.png new file mode 100644 index 0000000..8f4942e Binary files /dev/null and b/sc_images/first_recharge/price_selected_texture.png differ diff --git a/sc_images/first_recharge/price_tabs_bg.png b/sc_images/first_recharge/price_tabs_bg.png new file mode 100644 index 0000000..a5bbd60 Binary files /dev/null and b/sc_images/first_recharge/price_tabs_bg.png differ diff --git a/sc_images/first_recharge/reward_avatar_frame_1.png b/sc_images/first_recharge/reward_avatar_frame_1.png new file mode 100644 index 0000000..46e996a Binary files /dev/null and b/sc_images/first_recharge/reward_avatar_frame_1.png differ diff --git a/sc_images/first_recharge/reward_card.png b/sc_images/first_recharge/reward_card.png new file mode 100644 index 0000000..c222525 Binary files /dev/null and b/sc_images/first_recharge/reward_card.png differ diff --git a/sc_images/first_recharge/reward_card_coin.png b/sc_images/first_recharge/reward_card_coin.png new file mode 100644 index 0000000..87f4cee Binary files /dev/null and b/sc_images/first_recharge/reward_card_coin.png differ diff --git a/sc_images/first_recharge/reward_coin_stack.png b/sc_images/first_recharge/reward_coin_stack.png new file mode 100644 index 0000000..cba37b5 Binary files /dev/null and b/sc_images/first_recharge/reward_coin_stack.png differ diff --git a/sc_images/first_recharge/reward_frame.png b/sc_images/first_recharge/reward_frame.png new file mode 100644 index 0000000..618755e Binary files /dev/null and b/sc_images/first_recharge/reward_frame.png differ diff --git a/sc_images/first_recharge/reward_gift.png b/sc_images/first_recharge/reward_gift.png new file mode 100644 index 0000000..f8dd524 Binary files /dev/null and b/sc_images/first_recharge/reward_gift.png differ diff --git a/sc_images/first_recharge/reward_vehicle.png b/sc_images/first_recharge/reward_vehicle.png new file mode 100644 index 0000000..395468d Binary files /dev/null and b/sc_images/first_recharge/reward_vehicle.png differ diff --git a/sc_images/first_recharge/room_gift_entry_card.png b/sc_images/first_recharge/room_gift_entry_card.png new file mode 100644 index 0000000..af44ad8 Binary files /dev/null and b/sc_images/first_recharge/room_gift_entry_card.png differ diff --git a/sc_images/first_recharge/title.png b/sc_images/first_recharge/title.png new file mode 100644 index 0000000..ca292c8 Binary files /dev/null and b/sc_images/first_recharge/title.png differ diff --git a/sc_images/room/sc_icon_room_animation_effects_menu.png b/sc_images/room/sc_icon_room_animation_effects_menu.png new file mode 100644 index 0000000..fdeec5d Binary files /dev/null and b/sc_images/room/sc_icon_room_animation_effects_menu.png differ diff --git a/sc_images/room/sc_room_recharge_modal_coin.png b/sc_images/room/sc_room_recharge_modal_coin.png new file mode 100644 index 0000000..f2eac61 Binary files /dev/null and b/sc_images/room/sc_room_recharge_modal_coin.png differ diff --git a/test/gift_res_cp_relation_type_test.dart b/test/gift_res_cp_relation_type_test.dart new file mode 100644 index 0000000..de238e3 --- /dev/null +++ b/test/gift_res_cp_relation_type_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_system_invit_message_res.dart'; +import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; + +void main() { + group('SocialChatGiftRes relationType', () { + test('reads relationType from the gift payload', () { + final gift = SocialChatGiftRes.fromJson({ + 'id': 1, + 'giftTab': 'CP', + 'relationType': 'BROTHER', + }); + + expect(gift.relationType, 'BROTHER'); + expect(gift.toJson()['relationType'], 'BROTHER'); + }); + + test('reads alias relation fields from nested giftConfig', () { + final gift = SocialChatGiftRes.fromJson({ + 'giftConfig': {'id': 2, 'giftTab': 'CP', 'cpRelationType': 'SIS'}, + }); + + expect(gift.relationType, 'SIS'); + expect(scNormalizeCpRelationType(gift.relationType), 'SISTERS'); + }); + }); + + group('scNormalizeCpRelationType', () { + test('normalizes bro and sis aliases', () { + expect(scNormalizeCpRelationType('bro'), 'BROTHER'); + expect(scNormalizeCpRelationType('sis'), 'SISTERS'); + }); + }); + + group('scCpRelationTypeForGift', () { + test('uses explicit relation type before inferred gift text', () { + final gift = SocialChatGiftRes( + giftTab: 'CP', + relationType: 'bro', + giftName: 'sister crown', + ); + + expect(scCpRelationTypeForGift(gift), 'BROTHER'); + }); + + test('infers sister and cp relation from gift fields', () { + final sisterGift = SocialChatGiftRes(giftTab: 'CP', giftCode: 'vip_sis'); + final cpGift = SocialChatGiftRes(giftTab: 'CP', giftName: 'couple ring'); + + expect(scCpRelationTypeForGift(sisterGift), 'SISTERS'); + expect(scCpRelationTypeForGift(cpGift), 'CP'); + expect(scIsCpGift(sisterGift), isTrue); + }); + }); + + group('SCSystemInvitMessageRes avatar frame', () { + test('reads avatar frame fields from invite content', () { + final invite = SCSystemInvitMessageRes.fromJson({ + 'userAvatar': 'avatar.png', + 'userAvatarFrameUrl': 'frame.svga', + 'userAvatarFrameCover': 'frame.png', + }); + + expect(invite.userHeaddress, 'frame.svga'); + expect(invite.userHeaddressCover, 'frame.png'); + expect(invite.toJson()['userHeaddress'], 'frame.svga'); + }); + }); +} diff --git a/test/sc_cp_rights_config_res_test.dart b/test/sc_cp_rights_config_res_test.dart new file mode 100644 index 0000000..491fa57 --- /dev/null +++ b/test/sc_cp_rights_config_res_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_cp_rights_config_res.dart'; + +void main() { + group('SCCpRightsConfigRes', () { + test('parses the updated rights config body', () { + final config = SCCpRightsConfigRes.fromJson({ + 'relationType': 'CP', + 'cpUserId': null, + 'relationshipStatus': 'NONE', + 'intimacyValue': 0, + 'levels': [ + { + 'level': 1, + 'levelName': 'CP 1级', + 'requiredIntimacyValue': 0, + 'unlocked': false, + 'rights': [ + { + 'rightKey': 'CP_CABIN_LEVEL_1', + 'icon': null, + 'title': 'CP 1级', + 'desc': 'Unlock when intimacy reaches 0', + 'unlocked': false, + }, + ], + }, + ], + }); + + expect(config.relationType, 'CP'); + expect(config.cpUserId, isNull); + expect(config.relationshipStatus, 'NONE'); + expect(config.intimacyValue, 0); + expect(config.levels, hasLength(1)); + expect(config.levels.first.levelName, 'CP 1级'); + expect(config.levels.first.requiredIntimacyValue, 0); + expect(config.levels.first.unlocked, false); + expect(config.levels.first.rights.first.rightKey, 'CP_CABIN_LEVEL_1'); + expect(config.levels.first.rights.first.title, 'CP 1级'); + }); + + test('also accepts a full response envelope', () { + final config = SCCpRightsConfigRes.fromJson({ + 'status': true, + 'errorCode': 0, + 'body': { + 'relationType': 'SISTERS', + 'cpUserId': 123, + 'relationshipStatus': 'ACTIVE', + 'intimacyValue': 1200, + 'levels': [ + { + 'level': 2, + 'levelName': 'Sisters 2', + 'requiredIntimacyValue': 1000, + 'unlocked': true, + 'rights': [], + }, + ], + }, + }); + + expect(config.relationType, 'SISTERS'); + expect(config.cpUserId, '123'); + expect(config.relationshipStatus, 'ACTIVE'); + expect(config.intimacyValue, 1200); + expect(config.levels.first.level, 2); + expect(config.levels.first.unlocked, true); + }); + }); +} diff --git a/需求进度.md b/需求进度.md index 59076e1..1bbf532 100644 --- a/需求进度.md +++ b/需求进度.md @@ -1,3 +1,223 @@ +# 首充弹窗需求进度(2026-05-21) + +- [x] 2026-05-22 用户反馈:礼物弹窗在 iPhone 17 Pro Max 仍报 `A Stack requires bounded constraints from its parent`;本轮已实际运行 iPhone 17 Pro Max 复现,并依据运行日志修到不再报错。 +- [x] 2026-05-22 已确认 iPhone 17 Pro Max 模拟器在线,设备 ID:`8D2D87EB-4769-43B7-8DC2-580D46A76B6C`。 +- [x] 2026-05-22 已在 iPhone 17 Pro Max 启动主 App;控制台先出现 SmartDialog overlay 运行期异常:`RenderAnimatedOpacity ... SmartDialogWidget ... size: MISSING`,说明问题发生在弹窗 overlay 内容尺寸约束层。 +- [!] 2026-05-22 主 App 运行时有自动签到弹窗/页面跳转干扰,直接从首页进入房间礼物面板不稳定;改为创建只用于验证的礼物弹窗 debug 入口,复用真实 `GiftPage` 与 `SmartDialog.show(tag: showGiftControl)` 路径在 iPhone 17 Pro Max 直接复现。 +- [x] 2026-05-22 已新增 `lib/debug/gift_page_preview.dart`,用于在 iPhone 17 Pro Max 直接运行真实 `GiftPage` 礼物弹窗,避免主 App 自动弹窗和导航干扰。 +- [x] 2026-05-22 已执行 `dart format lib/debug/gift_page_preview.dart` 与 `flutter analyze lib/debug/gift_page_preview.dart lib/modules/gift/gift_page.dart`,结果:No issues found。 +- [!] 2026-05-22 首次运行 debug 礼物弹窗入口成功进入 Flutter,但入口自身有两个干扰:mock `balance()` 在 build 中通知 provider、`AppConfig` 未初始化;先修入口,避免干扰礼物弹窗约束验证。 +- [x] 2026-05-22 已修正 debug 礼物弹窗入口:补 `AppConfig.initialize()` / `DataPersistence.initialize()`,mock `balance()` 改为 no-op;复跑 format/analyze,结果 No issues found。 +- [x] 2026-05-22 已在 iPhone 17 Pro Max 礼物弹窗 debug 入口稳定复现用户报错;运行日志明确定位到 `lib/ui_kit/widgets/gift/sc_gift_combo_send_button.dart:80` 的 `Stack`,约束为 `BoxConstraints(w=112.6, 0.0<=h<=Infinity)`。 +- [x] 2026-05-22 已修复真正报错点:`SCGiftComboSendButton` 原本只有宽度没有高度,导致内部 `Stack` 在 Row/Column 中获得无界高度;现补 `height: 32.w`,不改礼物悬浮板、头像、列表样式。 +- [x] 2026-05-22 已在 iPhone 17 Pro Max 礼物弹窗 debug 入口热重启验证:等待 5 秒未再出现 `A Stack requires bounded constraints` 或连锁 layout 错误。 +- [x] 2026-05-22 已保存验证截图:`build/gift_page_verification/ios17promax_gift_panel_no_stack_error.png`。 +- [x] 2026-05-22 已继续观察 iPhone 17 Pro Max 礼物弹窗预览 10 秒,控制台无新增 `Stack requires bounded constraints`。 +- [x] 2026-05-22 已执行 `dart format lib/ui_kit/widgets/gift/sc_gift_combo_send_button.dart lib/modules/gift/gift_page.dart lib/debug/gift_page_preview.dart` 与对应 `flutter analyze`,结果:No issues found。 +- [x] 2026-05-22 已退出 iPhone 17 Pro Max 礼物弹窗预览 `flutter run` 会话,未保留运行进程。 +- [x] 2026-05-22 用户反馈:礼物弹窗仍报 `A Stack requires bounded constraints`;上轮不应改头像/loading 原有样式,本轮已还原无关样式改动,并按 CP 悬浮板方式把首充悬浮板放到上方。 +- [x] 2026-05-22 已还原上轮无关样式改动:麦位头像、指定用户头像、礼物 tab loading skeleton 恢复原本写法;本轮不再动这些原有样式。 +- [x] 2026-05-22 已将 `lib/modules/gift/gift_tab_page.dart` 恢复到仓库原样,避免上轮 loading skeleton 改动残留。 +- [x] 2026-05-22 已按 CP 悬浮板方式最小调整首充悬浮板:外层边距改为 `10.w`,卡片尺寸改为 `355.w × 46.w`,仍放在礼物主体 `ClipRRect` 上方。 +- [x] 2026-05-22 已执行 `dart format lib/modules/gift/gift_page.dart` 与 `flutter analyze lib/modules/gift/gift_page.dart`,结果:No issues found。 +- [x] 2026-05-22 已补充礼物弹窗根宽度约束:`SafeArea` 内新增 `SizedBox(width: ScreenUtil().screenWidth)` 包裹原 `Column`,只解决 SmartDialog 松约束下内部 `Stack/TabBarView` 无界问题,不改头像、列表、loading 等原有样式。 +- [x] 2026-05-22 已在根宽度约束修复后复跑 `dart format lib/modules/gift/gift_page.dart` 与 `flutter analyze lib/modules/gift/gift_page.dart`,结果:No issues found。 +- [x] 2026-05-22 根据 iPhone 17 Pro Max 实际日志确认根因是发送按钮高度无界,不是弹窗根宽度;已撤回 `SafeArea` 内新增的根 `SizedBox(width: ScreenUtil().screenWidth)`,继续保持原本弹窗布局。 +- [x] 2026-05-22 撤回根宽度约束后,已重新执行 format/analyze:`lib/modules/gift/gift_page.dart`、`lib/ui_kit/widgets/gift/sc_gift_combo_send_button.dart`、`lib/debug/gift_page_preview.dart` 均 No issues found。 +- [x] 2026-05-22 已重新冷启动 iPhone 17 Pro Max 礼物弹窗预览入口,启动完成后观察 10 秒,控制台无 `A Stack requires bounded constraints` 报错。 +- [x] 2026-05-22 已覆盖保存最终验证截图:`build/gift_page_verification/ios17promax_gift_panel_no_stack_error.png`。 +- [x] 2026-05-22 已退出最终 iPhone 17 Pro Max 礼物弹窗预览 `flutter run` 会话。 +- [x] 2026-05-22 新增问题:礼物弹窗弹起时报 `A Stack requires bounded constraints from its parent`,已定位并修复礼物弹窗内无界约束的 `Stack`。 +- [x] 2026-05-22 已定位礼物弹窗内高风险 `Stack`:麦位头像 `_maiHead()` 与指定用户头像叠选中角标时,处在 `Row/SingleChildScrollView` 的松约束链路中,需要补固定尺寸。 +- [x] 2026-05-22 已修复礼物弹窗头像叠层约束:指定用户头像 `Stack` 包裹 `30.w × 30.w`,麦位头像外层 `Stack` 包裹 `32.w × 32.w`、内层头像叠层包裹 `30.w × 30.w`。 +- [x] 2026-05-22 已执行 `dart format lib/modules/gift/gift_page.dart` 与 `flutter analyze lib/modules/gift/gift_page.dart`,结果:No issues found。 +- [x] 2026-05-22 已修复礼物 tab loading skeleton:`_buildGiftPageSkeleton()` 改为显式接收高度,并用有限宽高 `SizedBox` 包裹 `Stack`;空数据 loading 使用 `kGiftTabPageHeight.w`,分页 loading 使用 `_kGiftGridHeight.w`。 +- [x] 2026-05-22 已执行 `dart format lib/modules/gift/gift_page.dart lib/modules/gift/gift_tab_page.dart` 与 `flutter analyze lib/modules/gift/gift_page.dart lib/modules/gift/gift_tab_page.dart`,结果:No issues found。 +- [x] 2026-05-22 已完成本次报错修复收尾核对:本轮实际修复文件为 `lib/modules/gift/gift_page.dart`、`lib/modules/gift/gift_tab_page.dart` 与本进度文档;未改动其它业务逻辑。 +- [x] 2026-05-22 新增需求:用户未首充时,房间礼物弹窗上方新增首充悬浮板;CP 礼物 tab 保持特殊 CP 悬浮板,其它礼物 tab 展示 Figma 节点 `191:388` 样式;点击悬浮板打开首充弹窗。 +- [x] 2026-05-22 已开始定位礼物弹窗:`lib/modules/gift/gift_page.dart` 包含旧首充入口逻辑、CP 礼物 tab 判断与 `_buildCpGiftCloseFriendEntry()` 特殊 CP 悬浮板。 +- [x] 2026-05-22 已确认现有 CP 悬浮板实现:`_buildCpGiftCloseFriendEntry()` 使用 `sc_images/room/cp_profile/sc_cp_profile_gift_entry_card.png`,尺寸 `355.w × 46.w`,位于礼物弹窗主体上方。 +- [x] 2026-05-22 已通过本机 Figma Desktop MCP 读取节点 `191:388` 的结构化设计信息和截图。 +- [!] 2026-05-22 本机 Python 未安装 `requests`,改用 `curl` 直接访问 `http://127.0.0.1:3845/mcp`。 +- [x] 2026-05-22 已完成 Figma Desktop MCP 初始化握手,服务返回 `Figma Dev Mode MCP Server`。 +- [x] 2026-05-22 已读取 Figma 节点 `191:388` metadata:节点名 `Group 2090056029`,尺寸 `343 × 50`,主体由多层图片/遮罩组成。 +- [x] 2026-05-22 已读取 Figma 节点 `191:388` design context:样式依赖多张 PNG/SVG 资源和 mask;为保证 Flutter 还原度,本轮采用节点截图作为悬浮板资源。 +- [x] 2026-05-22 已通过 Figma Desktop MCP 获取节点 `191:388` screenshot。 +- [x] 2026-05-22 已将 Figma 节点 `191:388` 截图落地为项目资源:`sc_images/first_recharge/room_gift_entry_card.png`,尺寸 `343 × 50`。 +- [x] 2026-05-22 已完成礼物弹窗改造:移除旧顶部小首充 tag;在非 CP 礼物 tab、用户未首充、非审核状态下展示新的横向首充悬浮板。 +- [x] 2026-05-22 已更新 `GiftPage`:新增 `sc_images/first_recharge/room_gift_entry_card.png` 资源引用,普通礼物 tab 按 `firstRecharge=true` 显示横向悬浮板。 +- [x] 2026-05-22 已移除礼物弹窗顶部旧的小首充 tag,避免与新横向悬浮板重复展示。 +- [x] 2026-05-22 已新增 `_buildFirstRechargeGiftEntry()`,尺寸 `343.w × 50.w`,水平边距 `16.w`,位置与 CP 悬浮板同为礼物主体上方。 +- [x] 2026-05-22 已新增 `_openFirstRechargeDialog()`:点击首充悬浮板先关闭 `showGiftControl` 礼物面板,再延迟打开现有首充弹窗。 +- [x] 2026-05-22 已清理旧首充 tag 删除后不再使用的 `CountdownTimer` import。 +- [!] 2026-05-22 首次分析发现 `_openFirstRechargeDialog()` 延迟后使用 `BuildContext` 触发 lint,已改为延迟后重新获取 `navigatorKey.currentState` 并检查 `mounted`。 +- [x] 2026-05-22 已复跑 `dart format lib/modules/gift/gift_page.dart` 与 `flutter analyze lib/modules/gift/gift_page.dart`,结果:No issues found。 +- [x] 2026-05-22 已目检新增资源 `room_gift_entry_card.png`:显示 `Newcomer Special Offer`、金币和礼盒,符合 Figma 节点 `191:388` 悬浮板视觉。 +- [x] 2026-05-22 已完成收尾核对:当前工作区存在多项此前/并行任务改动,本轮新增悬浮板核心变更为 `GiftPage`、新增首充悬浮板图片与本进度文档;未回退无关改动。 +- [x] 2026-05-22 用户反馈 3:第二档金额 tab 右侧仍离选择器边框有间距;需要继续微调第二档选中态贴边,并说明当前首充弹窗逻辑。 +- [x] 2026-05-22 已定位第二档右侧留白根因:选择器总宽 `240`,第二档选中态仍按 `left=120,width=110` 渲染,因此右侧天然剩 `10px`。 +- [x] 2026-05-22 已将第二档选中态改为 `left=120,width=120`,右侧直接铺到选择器边框;第二档文字改为 `left=125,width=110`,保持在右半区居中。 +- [x] 2026-05-22 已针对第二档贴边修正执行 `dart format` 和定向 `flutter analyze`,结果:No issues found。 +- [x] 2026-05-22 已重新运行 iPhone 17 Pro 预览,点击第二档并截图:`build/first_recharge_verification/ios17pro_first_recharge_tab2_flush_v1.png`;另生成 375x812 图 `ios17pro_first_recharge_tab2_flush_v1_375w.png`。 +- [x] 2026-05-22 已目检新版截图:第二档橙色选中态右侧已顶到选择器右边框,原先 10px 浅底留白已消失。 +- [x] 2026-05-22 已退出本轮 `flutter run` session,未保留运行命令占用。 +- [x] 2026-05-22 用户反馈 2:第二个金额 tab 选中态仍未对齐;首充奖励接口文档已提供,需要对接接口;点击 `Buy Now` 需要直接拉起对应 Google 支付。 +- [x] 2026-05-22 已读取 `/Users/nigger/Desktop/首充奖励-Flutter接口对接文档.md`,确认首充奖励首页接口为 `GET /go/app/first-recharge-reward/home`,金额档位和奖励列表由 `levelConfigs/rewardItems` 返回。 +- [x] 2026-05-22 已定位现有入口:主页首充浮层和礼物页均调用 `SCDialogUtils.showFirstRechargeDialog`;后续将该入口切到新版首充弹窗并接接口。 +- [x] 2026-05-22 已检查现有网络层和支付链路:`NetworkClient` 自动注入鉴权/语言/来源请求头并解析 `body`;`AndroidPaymentProcessor` 已由根 `Provider` 注册,现有 Google 内购商品配置来自 `/order/product-config`。 +- [x] 2026-05-22 正在新增首充奖励接口模型、仓库方法、弹窗数据转换和 `Buy Now` 直拉 Google 支付能力。 +- [x] 2026-05-22 已新增首充奖励接口响应模型 `SCFirstRechargeRewardHomeRes/LevelConfig/RewardItem`,兼容金额、折扣、总价值、非金币天数角标和金币奖励识别。 +- [x] 2026-05-22 已在 `SocialChatConfigRepository` / `SCConfigRepositoryImp` 增加 `firstRechargeRewardHome()`,请求 `GET /go/app/first-recharge-reward/home`。 +- [x] 2026-05-22 已将 `SCDialogUtils.showFirstRechargeDialog` 切换到新版 `FirstRechargeDialog.showFromRemote`,旧入口调用保持不变,主页/礼物页点击会走新接口弹窗。 +- [x] 2026-05-22 已为 `FirstRechargeDialogData` 增加接口数据转换:金额 tab、奖励卡、金币/非金币样式、折扣角标、总价值文案均来自接口字段或兼容扩展字段。 +- [x] 2026-05-22 已为 `AndroidPaymentProcessor` 增加 `processFirstRechargePurchase`:优先按接口返回的 `productPackage` 匹配 Google 商品,否则按 `rechargeAmountCents` 匹配价格并直接拉起 Google 支付。 +- [x] 2026-05-22 已调整第二个金额 tab 的选中贴图裁切:第二档使用右侧负偏移裁切镜像纹理,避免切换到第二档时贴图与边框错位。 +- [!] 2026-05-22 首次格式化误将 `需求进度.md` 传给 `dart format`,Dart 文件已格式化,Markdown 格式化报错不影响代码;下一步重新仅格式化 Dart 文件。 +- [!] 2026-05-22 首次定向分析发现支付文件存在若干历史 lint;本轮已清理未使用调试循环、空 `catch`、异步后使用 `BuildContext` 的写法,并将已直接导入的 `in_app_purchase_android` 补为显式依赖。 +- [x] 2026-05-22 已执行 `flutter pub get`,`in_app_purchase_android` 从传递依赖变为显式直接依赖。 +- [!] 2026-05-22 第二次格式化误将 `pubspec.yaml` 传给 `dart format`,`pub get` 与 Dart 文件格式化已成功;下一步仅对 Dart 文件执行格式化/分析。 +- [x] 2026-05-22 已重新仅格式化 Dart 文件,并执行定向 `flutter analyze`,结果:No issues found。 +- [x] 2026-05-22 准备在 iPhone 17 Pro 预览中切换到第二个金额 tab 截图,验证本轮对齐调整。 +- [x] 2026-05-22 已启动 `lib/debug/first_recharge_dialog_preview.dart` 到 iPhone 17 Pro 模拟器,应用进入 Flutter debug 运行状态。 +- [!] 2026-05-22 `simctl screenshot` 使用相对路径仍会被 iOS 工具误判目录不存在,改用绝对路径重新截图。 +- [x] 2026-05-22 已保存 iPhone 17 Pro 预览截图:`build/first_recharge_verification/ios17pro_first_recharge_api_tab1.png`。 +- [x] 2026-05-22 已在 Simulator 中点击 `$2.99` 第二档并保存截图:`build/first_recharge_verification/ios17pro_first_recharge_api_tab2.png`;另生成 375x812 对比图 `ios17pro_first_recharge_api_tab2_375w.png`。 +- [x] 2026-05-22 目检第二档金额区域:`$2.99` 文案居中,橙色选中态左侧与中间分割线对齐、右侧裁切贴合外边框,较上一版错位已修正。 +- [x] 2026-05-22 已退出本轮 `flutter run` session,未保留运行命令占用。 +- [x] 2026-05-22 已补充远程弹窗加载流程:首充接口返回并完成数据判定后先关闭 loading,再展示首充弹窗,避免 loading 遮住弹窗。 +- [x] 2026-05-22 已在加载流程修正后复跑 `dart format` 与定向 `flutter analyze`,结果:No issues found。 +- [x] 2026-05-22 已处理接口档位数量边界:当前 Figma 弹窗为两个金额 tab,接口映射时仅取前两个可用档位,避免第三档数据与两个 tab UI 状态不一致。 +- [x] 2026-05-22 已在档位数量边界修正后再次复跑 `dart format` 与定向 `flutter analyze`,结果:No issues found。 +- [x] 2026-05-22 用户反馈:顶部金额文字未居中,选中态没有和选择器边框对齐;本轮开始针对金额选择器做细微调整。 +- [x] 2026-05-22 已定位当前实现:金额区域在 `_PriceSelector` 中,选中态使用 `ClipRRect` 圆角裁剪,和 Figma 异形遮罩/边框咬合不够贴合。 +- [x] 2026-05-22 已将金额选中态改为 Figma SVG 同款自定义裁剪路径:选中第一档时右侧平直对齐中间边界,选中第二档时镜像处理;同时金额文字按 Figma 的 `top=7 height=16` 放回视觉中心。 +- [!] 2026-05-22 首次复跑分析发现 `Matrix4.scale` 为新版 Flutter 废弃 API,已改为 `Matrix4.diagonal3Values`。 +- [x] 2026-05-22 已复跑 `dart format` 和定向 `flutter analyze`,结果:No issues found。 +- [x] 2026-05-22 已在 iPhone 17 Pro 重新运行预览并截图:`build/first_recharge_verification/ios17pro_first_recharge_price_tuned.png`;另生成 375x812 对比图 `ios17pro_first_recharge_price_tuned_375w.png`。 +- [x] 2026-05-22 复查金额区域:`$0.99` / `$2.99` 已回到各自 tab 中心,橙色选中态右侧平直边已和中间分割边对齐,整体比上一版贴近 Figma。 +- [x] 2026-05-22 已退出本轮 `flutter run` session,未保留运行命令占用。 +- [x] 已确认本轮目标:按 Figma `<首充弹窗>` 先实现 UI,数据后续由接口决定;金额选项切换后下方奖励、折扣文案随数据变化。 +- [x] 已确认工作目录:`/Users/nigger/Documents/GitHub/chatapp3-flutter`。 +- [x] 已开始按“每一步同步更新本文件”的要求记录本轮进度,历史进度不覆盖。 +- [x] 已初步扫描项目文件,确认存在每日签到弹窗 `lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart`,本轮将优先复用其弹窗写法和项目 UI 规范。 +- [!] 内置 Figma app 读取节点 `144:1183` 返回重新认证提示;按本轮要求不走 Remote/OAuth,改为直接访问本机 Figma Desktop MCP:`http://127.0.0.1:3845/mcp`。 +- [x] 已确认本机 Figma Desktop MCP 服务在线;未初始化 session 的请求返回 `Invalid sessionId`,下一步执行 MCP 初始化握手。 +- [x] 已完成 Figma Desktop MCP 初始化握手,服务返回 `Figma Dev Mode MCP Server`。 +- [x] 已通过 Desktop MCP 列出可用工具:`get_metadata`、`get_design_context`、`get_screenshot`、`get_variable_defs` 等。 +- [x] 已通过 Desktop MCP 读取节点 `144:1183` metadata:该节点本身即目标 frame `首充弹窗`,尺寸 `375x812`;弹窗主体 `340x528`,位置 `x=18 y=153`。 +- [x] 已通过 Desktop MCP 读取节点 `144:1183` design context;关键图层包括背景遮罩、弹窗三段背景、顶部标题图、金额切换条、6 个奖励卡片、`Buy Now` 按钮和折扣角标。 +- [x] 已通过 Desktop MCP 读取节点 `144:1183` screenshot,并保存为 `build/first_recharge_verification/figma_reference.png` 作为视觉基准。 +- [ ] 正在检查 Flutter 项目主题、组件、资源注册方式和每日签到弹窗实现细节。 +- [x] 已检查 `pubspec.yaml`:资源按目录注册,现有 `daily_sign_in/`、`register_reward/` 均通过 `sc_images/...` 目录管理。 +- [x] 已检查每日签到弹窗:采用 `SmartDialog.show`、`ScreenUtil`、`Stack + Image.asset` 叠图层,并在弹窗内部定义数据模型与 mock 数据。 +- [x] 已确认奖励图标策略可复用:金币样式复用 `sc_images/register_reward/yumi_coin.png`;非金币样式支持接口图片 URL 或本地占位。 +- [x] 已检查当前 git 状态:除本轮 `需求进度.md` 外,已有无关改动 `lib/shared/data_sources/sources/local/floating_screen_manager.dart`,本轮不会回退。 +- [x] 已下载 Figma 首充弹窗素材到 `sc_images/first_recharge/`,包含弹窗三段背景、标题图、金额切换条、奖励卡、按钮、关闭按钮和折扣角标。 +- [x] 已按展示尺寸将关闭按钮、标题、按钮、折扣角标和奖励小图压缩到约 3x 资源尺寸,避免 Figma 小图超大分辨率直接进包。 +- [x] 已在 `pubspec.yaml` 注册 `sc_images/first_recharge/` 资源目录。 +- [x] 已新增 `lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart`:包含首充套餐数据模型、金额切换、奖励列表、金币/非金币两种奖励样式、折扣角标和 Buy Now 点击回调。 +- [x] 已新增 `lib/debug/first_recharge_dialog_preview.dart`:使用 Figma 背景图和 `SmartDialog` 遮罩展示首充弹窗,供 iOS 模拟器截图验证。 +- [x] 已执行 `dart format lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart lib/debug/first_recharge_dialog_preview.dart`,格式化完成。 +- [!] 首次定向 `flutter analyze` 发现 `previewBackground` 私有常量未使用,已删除。 +- [x] 已复跑 `flutter analyze lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart lib/debug/first_recharge_dialog_preview.dart`,结果:No issues found。 +- [x] 已确认 iPhone 17 Pro 模拟器已启动,设备 ID:`49E28192-AC58-4EE3-BF9E-0363C4A47536`。 +- [ ] 正在运行 `lib/debug/first_recharge_dialog_preview.dart` 到 iPhone 17 Pro 模拟器。 +- [!] iOS 构建遇到 Xcode concurrent builds 重试;已确认冲突来自另一个正在编译 `lib/main.dart` 的 iPhone 17 Pro Max 构建,本轮暂不主动终止无关运行进程。 +- [!] 已截取第一张 iPhone 17 Pro 图,但前台显示为既有主 App 每日签到弹窗,不是本轮 preview;判断为同一模拟器已有 Runner/debug session 抢占前台,需终止 Runner 后干净重跑。 +- [x] 已退出本轮 `flutter run` session,并确认 iPhone 17 Pro 上当前没有正在运行的 `com.org.yumiparty`。 +- [ ] 正在干净重跑首充弹窗 debug 预览入口。 +- [x] 已重新截图 `build/first_recharge_verification/ios17pro_first_recharge_v2.png`,首充弹窗可见,主体布局接近 Figma。 +- [!] 视觉复查发现 debug 预览背景自带状态栏,同时模拟器真实状态栏又叠加一层;已在 debug 预览入口隐藏系统 UI,准备重新截图。 +- [x] 已重新截图 `build/first_recharge_verification/ios17pro_first_recharge_v3.png`,状态栏重复问题已解决;另生成 375x812 缩放对比图 `ios17pro_first_recharge_v3_375w.png`。 +- [!] 缩放对比发现金额切换条价格文字整体右偏,`$0.99` 与 `$2.99` 坐标不符合 Figma;已按 price tab 内部相对坐标修正。 +- [x] 已在价格文字修正后复跑 `dart format` 与定向 `flutter analyze`,结果:No issues found。 +- [x] 已热重启 iPhone 17 Pro 预览并截取最终图:`build/first_recharge_verification/ios17pro_first_recharge_final.png`;另生成 375x812 对比图 `ios17pro_first_recharge_final_375w.png`。 +- [x] 最终视觉复查:弹窗主体位置、三段背景、标题、倒计时、金额 tab、6 个奖励卡、总价值文案、Buy Now 按钮和折扣角标已贴近 Figma;金额切换状态的数据结构已支持选择不同金额显示不同奖励。 +- [!] 剩余客观差异:iPhone 17 Pro 模拟器会显示 Dynamic Island 黑区,而 Figma 截图尺寸为 `375x812` 的静态状态栏背景;这属于设备壳层差异,不影响弹窗主体。 +- [x] 已退出首充弹窗 `flutter run` session,未保留本轮运行命令占用。 +- [x] 已完成最终 git 状态检查:本轮新增/修改集中在首充弹窗组件、debug 入口、`sc_images/first_recharge/`、`pubspec.yaml` 和本进度文件;工作区仍存在其他房间/动效相关改动,判断为本轮之外的既有改动,未回退。 +- [x] 已复查资源体积:将仅用于 debug 预览的背景图压到 `375x812`,并删除未使用的 `price_selected_mask.svg`。 +- [x] 已在资源整理后最终复跑定向 `flutter analyze`,结果:No issues found。 +- [ ] 待检查 Flutter 项目主题、组件、资源注册方式和每日签到弹窗实现细节。 +- [ ] 待实现首充弹窗 UI、预览入口与占位数据模型。 +- [ ] 待启动 iOS 模拟器截图并与 Figma 截图对比迭代。 + +--- + +# 语音房内动画开关需求进度(2026-05-21) + +- [x] 2026-05-22 收到第三次反馈:菜单特效图标需改用 `/Users/nigger/Desktop/app素材/figma-20260522-120235.png`,并且外层要和其它菜单图标一样用 `Container` 包裹。 +- [x] 2026-05-22 已检查本地图片:`figma-20260522-120235.png` 为 45×45 PNG,可直接作为房间菜单图标资源。 +- [x] 2026-05-22 已用本地图片覆盖项目资源 `sc_images/room/sc_icon_room_animation_effects_menu.png`。 +- [x] 2026-05-22 已修改菜单特效入口:外层改为和音乐/红包一致的 45×45 圆形 `Container`,内部居中渲染图标。 +- [x] 2026-05-22 已执行 `dart format lib/ui_kit/widgets/room/room_menu_dialog.dart`。 +- [x] 2026-05-22 已执行 `flutter analyze --no-fatal-warnings --no-fatal-infos lib/ui_kit/widgets/room/room_menu_dialog.dart`,结果 `No issues found`。 +- [x] 2026-05-22 已检查最终状态:图标资源已为 45×45 PNG,菜单特效入口代码已引用该资源并包裹 45×45 圆形 `Container`;工作区仍有大量既有/并行改动,本轮仅处理菜单图标和进度记录。 + +- [x] 2026-05-22 收到二次反馈:当前功能效果正确,但弹窗 UI 样式不对;需要重新尝试连接 Figma Lalu 节点 `171:264`,并将菜单栏 UI 图标换成节点 `171:261` 对应样式。 +- [x] 2026-05-22 已重新连接本机 Figma Desktop MCP:`http://127.0.0.1:3845/mcp`,session 为 `177330e2-d0e2-4cdb-9429-febe997ba070`,服务为 `Figma Dev Mode MCP Server`。 +- [x] 2026-05-22 已通过 Desktop MCP 读取节点 `171:264` metadata / design context:目标为 375×812 整屏,底部弹层从 y=661 起,高 151,背景 `#072121`,顶部圆角 12,标题 `Animation` 18px,中间两行文字 14px,开关尺寸 32×14。 +- [x] 2026-05-22 已通过 Desktop MCP 读取节点 `171:261` metadata / design context:菜单图标为 26×26 vector,素材地址由本机 Figma assets 服务返回。 +- [x] 2026-05-22 已获取 `171:264` / `171:261` 截图数据:Figma screenshot 以 MCP 内联 PNG 返回,后续从本地 SSE 文件落盘用于视觉对照。 +- [x] 2026-05-22 已检查当前实现:旧弹窗为居中 330 宽卡片,和 Figma 底部 375×151 半屏面板不一致;菜单仍使用旧 `sc_icon_room_special_effects.png` 资源,不是节点 `171:261` 的 26×26 vector。 +- [x] 2026-05-22 已将 Figma 截图落盘:`build/voice_room_animation_switch_verification/figma_171_264_animation_switch.png`、`build/voice_room_animation_switch_verification/figma_171_261_menu_icon.png`。 +- [x] 2026-05-22 已将节点 `171:261` 菜单图标导出到项目资源:`sc_images/room/sc_icon_room_animation_effects_menu.png`,尺寸 26×26。 +- [x] 2026-05-22 已目检 Figma 截图:弹窗应为底部全宽面板,不是居中卡片;标题为 `Animation` 且无左侧图标/关闭按钮;行文本分别为 `Gift Animation`、`Vehicle Animation`;开关为 32×14 小胶囊。 +- [x] 2026-05-22 已确认 Figma 开关素材:开启态为 `#18F2B1` 描边与右侧 10px 圆点,关闭态为 `#999999` 描边与左侧 10px 圆点;当前用 Flutter 自绘,避免 SVG CSS 变量兼容问题。 +- [x] 2026-05-22 已完成编码:弹窗改为底部 151 高面板、自绘 32×14 开关;菜单图标改为 `sc_icon_room_animation_effects_menu.png` 并按 26×26 渲染。 +- [x] 2026-05-22 已执行 `dart format` 格式化 `room_animation_switch_dialog.dart` 与 `room_menu_dialog.dart`。 +- [x] 2026-05-22 已执行 `flutter analyze --no-fatal-warnings --no-fatal-infos lib/ui_kit/widgets/room/room_animation_switch_dialog.dart lib/ui_kit/widgets/room/room_menu_dialog.dart lib/debug/room_animation_switch_dialog_preview.dart`,结果 `No issues found`。 +- [x] 2026-05-22 已调整 debug preview:`RoomAnimationSwitchDialog` 在预览中同样贴底展示,不再居中。 +- [x] 2026-05-22 已确认 iPhone 17 Pro Max 模拟器在线,设备 ID `8D2D87EB-4769-43B7-8DC2-580D46A76B6C`。 +- [x] 2026-05-22 已复跑 `flutter analyze --no-fatal-warnings --no-fatal-infos`,结果 `No issues found`。 +- [x] 2026-05-22 已重新运行 iPhone 17 Pro Max 预览,Flutter run 已进入热重载状态。 +- [!] 2026-05-22 首次 `simctl screenshot` 使用相对路径时被 iOS 工具误判目录不存在,已改用绝对路径重新截图。 +- [x] 2026-05-22 已保存新版弹窗预览截图:`build/voice_room_animation_switch_verification/ios17promax_animation_switch_dialog_figma_style_v1.png`。 +- [!] 2026-05-22 已目检新版预览截图:底部位置/高度/颜色/标题结构与 Figma 对齐,但 debug preview 因全局默认值让第二个开关显示开启,Figma 基准为关闭;准备仅调整 debug preview 初始状态后复截。 +- [x] 2026-05-22 已调整 debug preview 初始状态:礼物开关开启、进场开关关闭,仅用于视觉对比;生产弹窗仍读取用户真实偏好。 +- [x] 2026-05-22 已再次分析 debug preview 与弹窗文件,结果 `No issues found`。 +- [!] 2026-05-22 尝试对当前 `flutter run` 会话发送热重启时发现 stdin 已关闭,改为重新拉起预览目标后复截。 +- [x] 2026-05-22 已清理旧的 iPhone 17 Pro Max 预览 `flutter run` / Runner 进程。 +- [x] 2026-05-22 已重新启动预览并进入热重载状态。 +- [x] 2026-05-22 已保存第二版对比截图:`build/voice_room_animation_switch_verification/ios17promax_animation_switch_dialog_figma_style_v2.png`。 +- [x] 2026-05-22 已目检第二版截图:底部面板位置/高度/圆角/背景色、标题与两行开关状态已贴近 Figma 171:264;截图保存为 `ios17promax_animation_switch_dialog_figma_style_v2.png`。 +- [!] 2026-05-22 停止预览时工具侧 stdin 再次关闭,已改用进程清理方式结束本次 `flutter run` / Runner。 +- [x] 2026-05-22 已确认 iPhone 17 Pro Max 预览相关 `flutter run` / Runner 进程已退出。 +- [x] 2026-05-22 已检查最终工作区状态:本轮新增 `sc_images/room/sc_icon_room_animation_effects_menu.png`,并更新动画开关弹窗、房间菜单与 debug preview;工作区仍存在首充弹窗、`floating_screen_manager.dart`、`pubspec.yaml` 等既有/并行改动,本轮不回退。 + +- [x] 已确认工作目录为 `/Users/nigger/Documents/GitHub/chatapp3-flutter`,并开始按“每一步同步更新本文件”的要求记录本轮进度。 +- [x] 已完成初步仓库巡检:项目为 Flutter 工程,资源通过 `pubspec.yaml` 注册,当前存在既有未提交改动 `lib/shared/data_sources/sources/local/floating_screen_manager.dart`,本轮不会回退无关改动。 +- [x] 已初读现有进度文档、`pubspec.yaml` 与房间/礼物/特效关键字,确认已有全局礼物特效配置、房间动效调度与语音房菜单相关实现需要继续定位。 +- [!] 已尝试使用内置 Figma app 读取节点 `171:264`,但该连接要求重新认证;按本轮要求不走 Remote/OAuth,改为直接访问本机 Figma Desktop MCP:`http://127.0.0.1:3845/mcp`。 +- [x] 已成功与 Figma Desktop MCP 完成初始化握手,服务返回 `Figma Dev Mode MCP Server`,后续节点读取将使用该本机 session。 +- [!] 已通过 Desktop MCP 调用 `get_metadata(nodeId: 171:264)`,当前 Figma active tab 返回未找到该节点;继续读取当前文件顶层结构以定位目标 frame。 +- [x] 已读取当前 Figma Desktop 文件顶层页面:`0:1 lalu`、`3:15367 yumi`,确认 Desktop MCP 当前连接的是目标 Lalu 文件上下文。 +- [x] 已扫描两页 metadata:`首充弹窗` 在 `yumi` 页定位到 frame `144:1183`;本轮“Entry Effect / Mic Animation”开关稿在 `yumi` 页房间弹窗区域出现,继续锁定具体 frame。 +- [x] 已通过 Desktop MCP 获取 `144:1183` 的 design context 与 screenshot,并保存视觉基准到 `build/voice_room_animation_switch_verification/figma_144_1183_first_recharge.png`。 +- [x] 已通过 Desktop MCP 获取当前可定位的房间设置页 `7:6301` 的 design context 与 screenshot,并保存到 `build/voice_room_animation_switch_verification/figma_7_6301_room_setup.png`;原链接节点 `171:259/171:264` 在当前 Desktop 文件中仍不可直接访问。 +- [x] 已完成第一轮代码定位:语音房菜单入口在 `lib/ui_kit/widgets/room/room_menu_dialog.dart`;房间可视特效总可见性来自 `RtcProvider.shouldShowRoomVisualEffects`;礼物特效开关已有 `SCGlobalConfig.isGiftSpecialEffects`,进场/座驾开关已有 `SCGlobalConfig.isEntryVehicleAnimation` 但当前播放链路仍需补齐独立判断。 +- [x] 已确认可复用素材:菜单新图标使用现有 `sc_images/room/sc_icon_room_special_effects.png`,开关控件复用 `sc_images/general/sc_icon_switch_on.png` / `sc_icon_switch_off.png`;无需新增资源目录。 +- [x] 已完成首轮编码:新增 `RoomAnimationSwitchDialog`,菜单中移除原 Settings 入口并替换为 `sc_icon_room_special_effects.png` 特效管理入口;弹窗内提供“礼物特效 / 进场特效”两个本地持久化开关。 +- [x] 已补齐播放链路判断:关闭礼物开关时同步关闭普通礼物与幸运礼物特效偏好;关闭进场开关时阻断高成本进场播放、低性能进场兜底与房间进场横幅队列。 +- [x] 已执行 `dart format`,格式化目标文件通过。 +- [!] 已执行定向 `flutter analyze`,结果无 error,但 `rtc_manager.dart` / `rtm_manager.dart` 既有 warning/info 让命令按 1 退出;继续使用 `--no-fatal-warnings --no-fatal-infos` 复核 error 口径。 +- [x] 已执行 `flutter analyze --no-fatal-warnings --no-fatal-infos ...`,退出码 0,确认本轮改动无 error;剩余 27 条 warning/info 均来自既有 `rtc_manager.dart` / `rtm_manager.dart` 风格问题。 +- [x] 已检查 iOS 模拟器设备:`iPhone 17 Pro Max` 已在线,设备 ID `8D2D87EB-4769-43B7-8DC2-580D46A76B6C`。 +- [x] 已启动 iPhone 17 Pro Max 运行当前 App;Flutter run 已进入可热重载状态,控制台确认 `lowPerformance=false`、`allowsHighCost=true`。 +- [x] 已通过模拟器 UI 进入语音房 `room12345678` 并打开底部菜单,确认原 Settings 入口已替换为 `Special effects management` 新图标入口。 +- [x] 已保存菜单验证截图:`build/voice_room_animation_switch_verification/ios17promax_room_menu_special_effects.png`。 +- [!] 继续点击新入口时 Computer Use 失去 Simulator 窗口句柄(`cgWindowNotFound`),无法继续通过 GUI 点开弹窗;已保留真实房间菜单验证截图,改用项目现有 debug preview 方式对同一个弹窗组件做 iPhone 17 Pro Max 截图验证。 +- [x] 已新增 `lib/debug/room_animation_switch_dialog_preview.dart`,复用同一个 `RoomAnimationSwitchDialog` 组件渲染 375x812 房间背景预览。 +- [x] 已分析弹窗预览入口,`flutter analyze --no-fatal-warnings --no-fatal-infos` 退出码 0;仍仅有既有 `rtc_manager.dart` / `rtm_manager.dart` warning/info。 +- [x] 已切换 iPhone 17 Pro Max 到弹窗预览入口,用于截图验证。 +- [!] 已截取首版弹窗预览图,发现英文标题在 375 宽下被截断为省略号;正在调整标题为两行可容纳布局并复截。 +- [x] 已热重载修正标题布局并复截弹窗图:`build/voice_room_animation_switch_verification/ios17promax_animation_switch_dialog_preview_v2.png`,标题、两行开关、按钮均未溢出。 +- [x] 已单独执行 `flutter analyze --no-fatal-warnings --no-fatal-infos lib/ui_kit/widgets/room/room_animation_switch_dialog.dart lib/debug/room_animation_switch_dialog_preview.dart`,结果 No issues found。 +- [x] 已退出弹窗预览运行,避免留下 `flutter run` 会话。 +- [x] 已检查最终变更范围:本轮核心改动集中在房间菜单、动画开关弹窗、礼物/进场特效播放判断与 debug 预览;工作区仍存在非本轮的 `floating_screen_manager.dart`、`pubspec.yaml`、首充弹窗相关文件改动,本轮不回退。 + +--- + # CP 进度弹窗 Desktop MCP 复核进度 - [x] 2026-05-15 本轮重新执行语音房 Agora -> TRTC 迁移:确认 Flutter RTC 口径改为 TRTC `strRoomId = roomProfile.roomAccount`、`userId = 业务 userId`,不再使用 Agora `roomProfile.id` 频道号和数字 uid。