This commit is contained in:
roxy 2026-05-22 14:19:53 +08:00
parent 2476e6d94c
commit 79e78cd897
80 changed files with 4930 additions and 1169 deletions

View File

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

View File

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

View File

@ -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<void> 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<SCAppGeneralManager>(
create: (_) => _PreviewAppGeneralManager(),
),
ChangeNotifierProvider<RtcProvider>(create: (_) => RtcProvider()),
ChangeNotifierProvider<RtmProvider>(create: (_) => RtmProvider()),
ChangeNotifierProvider<SocialChatUserProfileManager>(
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<SocialChatGiftRes>.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<void> fetchCountryList() async {}
@override
Future<void> giftList({
bool includeCustomized = true,
bool forceRefresh = false,
}) async {}
@override
void giftActivityList() {}
@override
Future<void> giftBackpack({bool forceRefresh = false}) async {}
@override
Future<void> 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() {}
}

View File

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

View File

@ -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<SocialChatUserProfileManager>(
create: (_) => _PreviewUserProfileManager(),
),
ChangeNotifierProvider<AndroidPaymentProcessor>(
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);
}
}

View File

@ -276,6 +276,8 @@ class RootAppWithProviders extends StatelessWidget {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
final RouteObserver<ModalRoute<dynamic>> modalRouteObserver =
RouteObserver<ModalRoute<dynamic>>();
class YumiApplication extends StatefulWidget {
const YumiApplication({super.key});
@ -429,7 +431,7 @@ class _YumiApplicationState extends State<YumiApplication>
);
},
),
navigatorObservers: [routeObserver],
navigatorObservers: [routeObserver, modalRouteObserver],
),
),
);

View File

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

View File

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

View File

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

View File

@ -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<GiftPage> 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<GiftPage> 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<GiftPage> 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 = <String>[];
void addValue(dynamic value) {
@ -684,6 +702,11 @@ class _GiftPageState extends State<GiftPage> 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<GiftPage> 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<GiftPage> 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<GiftPage> 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<GiftPage> with TickerProviderStateMixin {
Future<void> _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<GiftPage> 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<GiftPage> 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<GiftPage> 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<GiftPage> 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<GiftPage> 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<GiftPage> 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<void>.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<void>.delayed(const Duration(milliseconds: 80), () {

View File

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

View File

@ -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<VoiceRoomPage>
int _shownRoomStartupFailureToken = 0;
RtcProvider? _rtcProvider;
bool _roomProviderRebuildScheduled = false;
PageRoute<dynamic>? _routeObserverRoute;
ModalRoute<dynamic>? _routeObserverRoute;
SCRoomTopLayerGuardLease? _coveredRouteGuardLease;
bool _roomRouteVisible = false;
@override
@ -89,6 +91,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
context,
listen: false,
).setVoiceRoomRouteVisible(true);
SCRoomTopLayerGuard().activeListenable.addListener(
_handleRoomTopLayerGuardChanged,
);
_enableRoomVisualEffects();
_tabController.addListener(_handleTabChange);
_subscription = eventBus.on<SCGiveRoomLuckPageDisposeEvent>().listen((
@ -119,21 +124,29 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
context.read<RoomMusicManager>().attachRtcProvider(rtcProvider);
}
final route = ModalRoute.of(context);
if (route is PageRoute<dynamic> && _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<VoiceRoomPage>
@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<VoiceRoomPage>
_enableRoomVisualEffects();
}
void _clearTransientRoomVisualEffects({required String reason}) {
if (!mounted) {
return;
}
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
RoomEntranceHelper.clearQueue();
Provider.of<GiftProvider>(context, listen: false).clearAllGiftData();
Provider.of<GiftAnimationManager>(
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<RtcProvider>(context, listen: false);
final currentRoomId =

View File

@ -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<RoomGameListSheet> {
_launchingGameId = game.gameId;
});
SCRoomTopLayerGuardLease? gameDialogGuard;
try {
_log(
'launch_request roomId=$_roomId gameId=${game.gameId} '
@ -147,23 +149,38 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
return;
}
gameDialogGuard = SCRoomTopLayerGuard().acquire(reason: 'room_game_page');
Navigator.of(context).pop();
await Future<void>.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 {

View File

@ -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<bool> _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<SocialChatUserProfileManager>(
context,
listen: false,
).fetchUserProfileData(loadGuardCount: false);
SCLoadingManager.hide();
})
.catchError((e) {
SCLoadingManager.hide();
});
final localizations = SCAppLocalizations.of(context)!;
final profileManager = Provider.of<SocialChatUserProfileManager>(
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();
}
}
}

View File

@ -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<bool> _use(BagsListRes res, bool unload) async {
SCLoadingManager.show(context: context);
final profileManager = Provider.of<SocialChatUserProfileManager>(
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();
}
}
}

View File

@ -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<bool> _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<SocialChatUserProfileManager>(
context,
listen: false,
).fetchUserProfileData(loadGuardCount: false);
SCLoadingManager.hide();
})
.catchError((e) {
SCLoadingManager.hide();
});
final localizations = SCAppLocalizations.of(context)!;
final profileManager = Provider.of<SocialChatUserProfileManager>(
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();
}
}
}

View File

@ -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<bool> _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<SocialChatUserProfileManager>(
context,
listen: false,
).fetchUserProfileData(loadGuardCount: false);
SCLoadingManager.hide();
})
.catchError((e) {
SCLoadingManager.hide();
});
final localizations = SCAppLocalizations.of(context)!;
final profileManager = Provider.of<SocialChatUserProfileManager>(
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();
}
}
}

View File

@ -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<bool> _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<RealTimeCommunicationManager>(
context!,
listen: false,
).updateRoomBG(res);
} else {
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).updateRoomBG(SCRoomThemeListRes());
selecteTheme = null;
SCTts.show(SCAppLocalizations.of(context)!.successfullyUnloaded);
}
loadData(1);
///
String? groupId =
Provider.of<RealTimeCommunicationManager>(
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<RtmProvider>(
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<RealTimeCommunicationManager>(
context,
listen: false,
);
final rtmProvider = Provider.of<RtmProvider>(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();
}
}
}

View File

@ -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<WebViewPage> {
late WebViewController _controller;
late final SCRoomTopLayerGuardLease _topLayerGuardLease;
String _title = "";
double _progress = 0.0;
bool _isLoading = true;
@ -48,6 +50,7 @@ class _WebViewPageState extends State<WebViewPage> {
@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<WebViewPage> {
@override
void dispose() {
_controller.loadRequest(Uri.parse('about:blank'));
_topLayerGuardLease.release();
super.dispose();
}

View File

@ -8,6 +8,7 @@ import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/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 <String>{_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<String?>(
_roomRocketTop1AvatarUrl(launch.safeLevel),

View File

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

View File

@ -24,7 +24,7 @@ class AndroidPaymentProcessor extends ChangeNotifier {
//
bool _isAvailable = false;
String _errorMessage = '';
List<SelecteProductConfig> _products = [];
final List<SelecteProductConfig> _products = [];
List<PurchaseDetails> _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<void> 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<void> 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<PurchaseDetails> purchases) {
_purchases = purchases;
@ -227,22 +294,29 @@ class AndroidPaymentProcessor extends ChangeNotifier {
//
Future<void> _handleAlreadyOwnedItem(PurchaseDetails purchase) async {
final profileManager = Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
);
try {
//
if (_getProductType(purchase.productID) == 'consumable') {
await consumePurchase(purchase);
//
Provider.of<SocialChatUserProfileManager>(
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<void> _verifyPayment(PurchaseDetails purchase) async {
final profileManager = Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
);
try {
// 1.
if (purchase.verificationData.serverVerificationData.isEmpty) {
@ -283,14 +357,8 @@ class AndroidPaymentProcessor extends ChangeNotifier {
}
// 8.
Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
).fetchUserProfileData();
Provider.of<SocialChatUserProfileManager>(
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<void> _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}');
}
//

View File

@ -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<String, dynamic> json,
Map<String, dynamic>? giftConfig,
) {
const keys = <String>[
'relationType',
'relation_type',
'cpRelationType',
'cp_relation_type',
'relationshipType',
'relationship_type',
'relation',
'cpType',
'cp_type',
];
for (final key in keys) {
for (final source in <Map<String, dynamic>?>[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;

View File

@ -8,7 +8,9 @@ class SCCpRightsConfigRes {
}) : levels = levels ?? const [];
factory SCCpRightsConfigRes.fromJson(dynamic json) {
final source = json is Map ? json : const <String, dynamic>{};
final rawSource = json is Map ? json : const <String, dynamic>{};
final body = rawSource['body'] ?? rawSource['data'];
final source = body is Map ? body : rawSource;
final rawLevels =
source['levels'] ?? source['levelList'] ?? source['configs'];
return SCCpRightsConfigRes(

View File

@ -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<SCFirstRechargeLevelConfig> levelConfigs;
final String rewardStatus;
final SCFirstRechargeLevelConfig? matchedLevel;
final String rechargeAmount;
final int rechargeAmountCents;
final String rewardGroupId;
final String rewardGroupName;
final List<SCFirstRechargeRewardItem> rewardItems;
List<SCFirstRechargeLevelConfig> 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<SCFirstRechargeRewardItem> 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<String, dynamic> _asMap(dynamic value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return Map<String, dynamic>.from(value);
}
return const {};
}
List<dynamic> _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<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = _readInt(map[key]);
if (value > 0) {
return value;
}
}
return 0;
}
String _firstString(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = _readString(map[key]);
if (value.isNotEmpty) {
return value;
}
}
return '';
}
String _firstNonEmpty(List<String> values) {
for (final value in values) {
final text = value.trim();
if (text.isNotEmpty) {
return text;
}
}
return '';
}
String _readDiscountText(Map<String, dynamic> 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<String, dynamic> 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();
}

View File

@ -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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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<String> 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;
}

View File

@ -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<List<SCProductConfigRes>> productConfig();
///
Future<SCFirstRechargeRewardHomeRes> firstRechargeRewardHome();
///
Future<SCGooglePayRes> googlePay(
String product,

View File

@ -30,6 +30,7 @@ class OverlayManager {
static const Duration _rocketFloatingFallbackDedupWindow = Duration(
seconds: 30,
);
static const int _maxStackedFloatingVisibleCount = 2;
static const List<int> _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:

View File

@ -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<SCFirstRechargeRewardHomeRes> firstRechargeRewardHome() async {
final result = await http.get<SCFirstRechargeRewardHomeRes>(
"/go/app/first-recharge-reward/home",
extra: const {BaseNetworkClient.silentErrorToastKey: true},
fromJson: (json) => SCFirstRechargeRewardHomeRes.fromJson(json),
);
return result;
}
///order/purchase-pay/google
@override
Future<SCGooglePayRes> googlePay(

View File

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

View File

@ -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 <String?>[
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<String> 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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<VoidCallback> _deferredActions = Queue<VoidCallback>();
final ValueNotifier<bool> _activeListenable = ValueNotifier<bool>(false);
int _activeCount = 0;
bool get isActive => _activeCount > 0;
ValueListenable<bool> get activeListenable => _activeListenable;
SCRoomTopLayerGuardLease acquire({
required String reason,
Set<String> keepDialogTags = const <String>{},
}) {
_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<String> keepDialogTags = const <String>{}}) {
_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<VoidCallback>.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 <String>{},
});
final String reason;
final Widget child;
final Set<String> keepDialogTags;
@override
State<SCRoomTopLayerSuppressor> createState() =>
_SCRoomTopLayerSuppressorState();
}
class _SCRoomTopLayerSuppressorState extends State<SCRoomTopLayerSuppressor> {
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);
}
}

View File

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

View File

@ -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<FirstRechargeReward> rewards;
}
class FirstRechargeDialogData {
const FirstRechargeDialogData({
required this.packages,
this.timeRemainingText = '',
this.initialPackageIndex = 0,
});
final List<FirstRechargePackage> 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<FirstRechargePackage>? onBuyNow;
static Future<void> show(
BuildContext context, {
required FirstRechargeDialogData data,
ValueChanged<FirstRechargePackage>? 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<void> 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<void> _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<AndroidPaymentProcessor>(
context,
listen: false,
);
await processor.processFirstRechargePurchase(
context: context,
rechargeAmountCents: package.rechargeAmountCents,
productPackage: package.productPackage,
);
}
@override
State<FirstRechargeDialog> createState() => _FirstRechargeDialogState();
}
class _FirstRechargeDialogState extends State<FirstRechargeDialog> {
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<Widget> _buildRewardCards(
List<FirstRechargeReward> 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<Widget>.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<FirstRechargePackage> packages;
final int selectedIndex;
final double scale;
final ValueChanged<int> 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<Path> {
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<Shadow>? 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';
}

View File

@ -66,6 +66,7 @@ class _SCGiftComboSendButtonState extends State<SCGiftComboSendButton> {
return SizedBox(
width: widget.width,
height: 32.w,
child: ClipRRect(
borderRadius: borderRadius,
child: DecoratedBox(
@ -99,19 +100,21 @@ class _SCGiftComboSendButtonState extends State<SCGiftComboSendButton> {
),
),
),
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,
),
),
),
),
),

View File

@ -236,6 +236,7 @@ class _RoomAnimationQueueScreenState extends State<RoomAnimationQueueScreen> {
}
bool get _effectsEnabled =>
SCGlobalConfig.isEntryVehicleAnimation &&
Provider.of<RtcProvider>(
context,
listen: false,
@ -256,7 +257,9 @@ class _RoomAnimationQueueScreenState extends State<RoomAnimationQueueScreen> {
@override
Widget build(BuildContext context) {
final effectsEnabled = context.select<RtcProvider, bool>(
(rtcProvider) => rtcProvider.shouldShowRoomVisualEffects,
(rtcProvider) =>
SCGlobalConfig.isEntryVehicleAnimation &&
rtcProvider.shouldShowRoomVisualEffects,
);
if (!effectsEnabled) {
if (_animationQueue.isNotEmpty || _isQueueProcessing) {

View File

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

View File

@ -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<RtcProvider>(context, listen: false);
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
List<Widget> 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,

View File

@ -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<RoomRedPacketUiData> dataList, {
String? initialPacketId,
}) {
if (SCRoomTopLayerGuard().isActive) {
final deferredContext = context;
final deferredDataList = List<RoomRedPacketUiData>.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 <String>{RoomRedPacketOpenDialog.dialogTag},
child: RoomRedPacketOpenDialog(
data: packets[initialIndex],
dataList: packets,
initialIndex: initialIndex,
),
),
);
}

View File

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

View File

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

View File

@ -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<RoomAnimationSwitchDialog> createState() =>
_RoomAnimationSwitchDialogState();
}
class _RoomAnimationSwitchDialogState extends State<RoomAnimationSwitchDialog> {
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<void> _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<void> _setEntryEffectsEnabled(bool enabled) async {
final next = SCGlobalConfig.clampVisualEffectPreference(enabled);
setState(() {
_entryEffectsEnabled = next;
});
SCGlobalConfig.isEntryVehicleAnimation = next;
Provider.of<RtcProvider>(
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),
),
),
],
),
);
}
}

View File

@ -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<RoomBannerView> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Consumer<SCAppGeneralManager>(
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<RoomBannerView> createState() => _RoomBannerViewState();
}
class _RoomBannerViewState extends State<RoomBannerView> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Consumer<SCAppGeneralManager>(
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(),
),
),
],
),
);
}
}

View File

@ -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<RoomBottomWidget> {
animationType: SmartAnimationType.fade,
clickMaskDismiss: true,
builder: (_) {
return GiftPage();
return SCRoomTopLayerSuppressor(
reason: 'room_gift_panel',
child: GiftPage(),
);
},
);
}
@ -254,9 +258,12 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
maskColor: Colors.transparent,
clickMaskDismiss: true,
builder: (_) {
return RoomMenuDialog(roomMenuStime1, (eTime) {
roomMenuStime1 = eTime;
});
return SCRoomTopLayerSuppressor(
reason: 'room_menu_dialog',
child: RoomMenuDialog(roomMenuStime1, (eTime) {
roomMenuStime1 = eTime;
}),
);
},
);
},

View File

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

View File

@ -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<RoomHeadWidget> {
animationType: SmartAnimationType.fade,
maskColor: Colors.black54,
builder: (_) {
return SCEditRoomAnnouncementPage();
return SCRoomTopLayerSuppressor(
reason: 'room_announcement_dialog',
child: SCEditRoomAnnouncementPage(),
);
},
);
}

View File

@ -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<RoomMenuDialog> {
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<RoomMenuDialog> {
"red_packet/small_packet_icon.png",
),
);
if (Provider.of<RtcProvider>(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<RoomMenuDialog> {
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<RoomMenuDialog> {
} 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<RtcProvider>(
@ -306,6 +306,24 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
}
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}",

View File

@ -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<RoomOnlineUserWidget> {
alignment: Alignment.bottomCenter,
animationType: SmartAnimationType.fade,
builder: (_) {
return RoomGiftRankPage();
return SCRoomTopLayerSuppressor(
reason: 'room_gift_rank_dialog',
child: RoomGiftRankPage(),
);
},
);
},

View File

@ -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<RoomRechargeBottomSheet> createState() =>
_RoomRechargeBottomSheetState();
}
class _RoomRechargeBottomSheetState extends State<RoomRechargeBottomSheet> {
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<SocialChatUserProfileManager>(
paymentContext,
listen: false,
).balance();
if (!Platform.isAndroid) {
return;
}
final processor = Provider.of<AndroidPaymentProcessor>(
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<AndroidPaymentProcessor>(
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<void> _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<AndroidPaymentProcessor>(
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<SelecteProductConfig> 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,
),
),
),
],
),
),
);
}
}

View File

@ -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<RoomUserCardSetting> createState() => _RoomUserCardSettingState();
}
class _RoomUserCardSettingState extends State<RoomUserCardSetting> {
bool isOnMai = false;
bool micMute = false;
@override
void initState() {
super.initState();
isOnMai =
Provider.of<RtcProvider>(
context,
listen: false,
).userOnMaiInIndex(widget.userCardInfo?.userProfile?.id ?? "") !=
-1;
}
@override
Widget build(BuildContext context) {
final rtcProvider = Provider.of<RtcProvider>(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<Widget> list = [];
list.add(SizedBox(height: 10.w));
if (isOnMai) {
if (Provider.of<RtcProvider>(context, listen: false).isFz()) {
if (rtcProvider.isFz()) {
///
list.add(
_item(SCAppLocalizations.of(context)!.removeTheMic, null, () {
Navigator.of(context).pop();
Provider.of<RtcProvider>(
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<RtcProvider>(
context,
listen: false,
).killXiaMai(widget.userCardInfo?.userProfile?.id ?? "");
rtcProvider.killXiaMai(targetUserId);
}),
);
}
}
}
if (Provider.of<RtcProvider>(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<RoomUserCardSetting> {
}),
);
}
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<RoomUserCardSetting> {
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<RoomUserCardSetting> {
children: <Widget>[
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<RoomUserCardSetting> {
msg,
style: TextStyle(
fontSize: sp(16),
color:Colors.white,
color: Colors.white,
fontWeight: FontWeight.w400,
decoration: TextDecoration.none,
),

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

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

View File

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

View File

@ -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 运行当前 AppFlutter 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。