签到接口

This commit is contained in:
NIGGER SLAYER 2026-04-20 20:15:44 +08:00
parent 5b607800be
commit 5b0b5b862e
15 changed files with 1811 additions and 2676 deletions

View File

@ -34,7 +34,6 @@ import 'services/auth/authentication_manager.dart';
import 'services/gift/gift_animation_manager.dart';
import 'services/gift/gift_system_manager.dart';
import 'services/payment/google_payment_manager.dart';
import 'services/payment/mifa_pay_manager.dart';
import 'services/localization/localization_manager.dart';
import 'services/room/rc_room_manager.dart';
import 'services/audio/rtc_manager.dart';
@ -262,10 +261,6 @@ class RootAppWithProviders extends StatelessWidget {
lazy: true,
create: (context) => IOSPaymentProcessor(),
),
ChangeNotifierProvider<MiFaPayManager>(
lazy: true,
create: (context) => MiFaPayManager(),
),
ChangeNotifierProvider<ShopManager>(
lazy: true,
create: (context) => ShopManager(),

View File

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_res.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/modules/home/index_home_page.dart';
import 'package:yumi/modules/chat/message/sc_message_page.dart';
@ -318,6 +317,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
return;
}
_hasShownEntryDialogs = true;
debugPrint('[SignInReward][Home] start entry dialog flow');
await _waitForRegisterRewardSocketIfNeeded();
if (!mounted) {
@ -330,9 +330,18 @@ class _SCIndexPageState extends State<SCIndexPage> {
}
final dialogData = await _loadDailySignInDialogData();
if (!mounted) {
if (!mounted || dialogData == null) {
debugPrint(
'[SignInReward][Home] skip daily sign-in dialog reason=no-data',
);
return;
}
debugPrint(
'[SignInReward][Home] show daily sign-in dialog '
'checkedToday=${dialogData.checkedToday} '
'currentDay=${dialogData.currentItem?.day ?? 0} '
'items=${dialogData.items.length}',
);
_isShowingDailySignInDialog = true;
await DailySignInDialog.show(context, data: dialogData);
_isShowingDailySignInDialog = false;
@ -401,23 +410,33 @@ class _SCIndexPageState extends State<SCIndexPage> {
}
}
Future<DailySignInDialogData> _loadDailySignInDialogData() async {
DailySignInDialogData dialogData;
Future<DailySignInDialogData?> _loadDailySignInDialogData() async {
try {
final result = await Future.wait<dynamic>([
SCAccountRepository().checkInToday(),
SCAccountRepository().sginListAward(),
]);
final checkedToday = result[0] as bool;
final signInRes = result[1] as SCSignInRes;
dialogData = DailySignInDialogData.fromLegacy(
signInRes: signInRes,
checkedToday: checkedToday,
debugPrint('[SignInReward][Home] loading status');
final statusRes = await SCAccountRepository().signInRewardStatus();
if (!statusRes.canShowDialog) {
debugPrint(
'[SignInReward][Home] dialog disabled '
'configured=${statusRes.configured} '
'enabled=${statusRes.enabled} '
'available=${statusRes.available} '
'signedToday=${statusRes.signedToday} '
'days=${statusRes.days.length}',
);
return null;
}
final dialogData = DailySignInDialogData.fromStatus(statusRes: statusRes);
debugPrint(
'[SignInReward][Home] mapped dialog data '
'checkedToday=${dialogData.checkedToday} '
'currentDay=${dialogData.currentItem?.day ?? 0} '
'days=${dialogData.items.length}',
);
} catch (_) {
dialogData = DailySignInDialogData.mock();
return dialogData;
} catch (error) {
debugPrint('[SignInReward][Home] load status failed error=$error');
return null;
}
return dialogData;
}
Widget _buildBottomTabIcon({

View File

@ -1,120 +0,0 @@
import 'package:flutter/material.dart';
import 'package:yumi/shared/tools/sc_url_launcher_utils.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
import 'package:webview_flutter/webview_flutter.dart';
class MiFaPayWebViewPage extends StatefulWidget {
const MiFaPayWebViewPage({
super.key,
required this.requestUrl,
this.title = 'MiFaPay',
});
final String requestUrl;
final String title;
@override
State<MiFaPayWebViewPage> createState() => _MiFaPayWebViewPageState();
}
class _MiFaPayWebViewPageState extends State<MiFaPayWebViewPage> {
late final WebViewController _controller;
bool _isLoading = true;
double _progress = 0;
@override
void initState() {
super.initState();
_controller =
WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) async {
if (!SCUrlLauncherUtils.shouldOpenExternally(request.url)) {
return NavigationDecision.navigate;
}
final bool launched = await SCUrlLauncherUtils.launchExternal(
request.url,
);
if (launched) {
return NavigationDecision.prevent;
}
return SCUrlLauncherUtils.isHttpUrl(request.url)
? NavigationDecision.navigate
: NavigationDecision.prevent;
},
onProgress: (int progress) {
_updateState(() {
_progress = progress / 100;
});
},
onPageStarted: (_) {
_updateState(() {
_isLoading = true;
});
},
onPageFinished: (_) {
_updateState(() {
_isLoading = false;
_progress = 1;
});
},
onWebResourceError: (_) {
_updateState(() {
_isLoading = false;
});
},
),
)
..loadRequest(Uri.parse(widget.requestUrl));
}
@override
void dispose() {
super.dispose();
}
void _updateState(VoidCallback action) {
if (!mounted) {
return;
}
setState(action);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: SocialChatStandardAppBar(
title: widget.title,
actions: const <Widget>[],
backgroundColor: Colors.white,
backButtonColor: Colors.black,
gradient: const LinearGradient(
colors: <Color>[Colors.white, Colors.white],
),
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading || _progress < 1)
Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: _progress > 0 && _progress < 1 ? _progress : null,
minHeight: 2,
color: const Color(0xff18F2B1),
backgroundColor: const Color(0xffEDEDED),
),
),
],
),
);
}
}

View File

@ -1,463 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
enum RechargeMethodType { miFaPay, nativeStore }
class RechargeMethodOption {
const RechargeMethodOption({
required this.type,
required this.title,
this.assetIconPath,
this.iconData,
});
final RechargeMethodType type;
final String title;
final String? assetIconPath;
final IconData? iconData;
}
class RechargePackageOption {
const RechargePackageOption({
required this.id,
required this.coins,
required this.bonusCoins,
required this.priceLabel,
required this.badge,
});
final String id;
final int coins;
final int bonusCoins;
final String priceLabel;
final String badge;
}
class RechargeChannelOption {
const RechargeChannelOption({
required this.code,
required this.name,
this.typeLabel = '',
});
final String code;
final String name;
final String typeLabel;
}
class RechargeMethodBottomSheet extends StatefulWidget {
const RechargeMethodBottomSheet({
super.key,
required this.method,
required this.packages,
required this.channels,
required this.onConfirm,
this.initialPackageId,
this.initialChannelCode,
});
final RechargeMethodOption method;
final List<RechargePackageOption> packages;
final List<RechargeChannelOption> channels;
final void Function(
RechargePackageOption package,
RechargeChannelOption channel,
)
onConfirm;
final String? initialPackageId;
final String? initialChannelCode;
@override
State<RechargeMethodBottomSheet> createState() =>
_RechargeMethodBottomSheetState();
}
class _RechargeMethodBottomSheetState extends State<RechargeMethodBottomSheet> {
late String _selectedPackageId;
late String _selectedChannelCode;
@override
void initState() {
super.initState();
_selectedPackageId =
widget.packages.isEmpty
? ''
: widget.initialPackageId ?? widget.packages.first.id;
_selectedChannelCode =
widget.channels.isEmpty
? ''
: widget.initialChannelCode ?? widget.channels.first.code;
}
@override
Widget build(BuildContext context) {
final bool canConfirm =
widget.packages.isNotEmpty &&
widget.channels.isNotEmpty &&
_selectedPackageId.isNotEmpty &&
_selectedChannelCode.isNotEmpty;
return SafeArea(
top: false,
child: Container(
width: ScreenUtil().screenWidth,
height: MediaQuery.of(context).size.height * 0.72,
padding: EdgeInsets.fromLTRB(14.w, 14.w, 14.w, 14.w),
decoration: BoxDecoration(
color: const Color(0xff03523a),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(14.w),
topRight: Radius.circular(14.w),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
widget.method.title,
style: TextStyle(
fontSize: 18.sp,
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
SCDebounceWidget(
onTap: () => Navigator.of(context).pop(),
child: Icon(
CupertinoIcons.clear_thick_circled,
size: 22.w,
color: Colors.white.withValues(alpha: 0.86),
),
),
],
),
SizedBox(height: 6.w),
Text(
'Select amount',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.68),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 16.w),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.packages.isEmpty)
_buildEmptyHint('MiFaPay products are loading')
else
GridView.builder(
itemCount: widget.packages.length,
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 10.w,
crossAxisSpacing: 10.w,
mainAxisExtent: 136.w,
),
itemBuilder: (BuildContext context, int index) {
final RechargePackageOption item =
widget.packages[index];
final bool isSelected = item.id == _selectedPackageId;
return _buildPackageCard(item, index, isSelected);
},
),
SizedBox(height: 16.w),
Text(
'Payment channel',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.68),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 10.w),
if (widget.channels.isEmpty)
_buildEmptyHint('No channel available')
else
Wrap(
spacing: 10.w,
runSpacing: 10.w,
children: widget.channels
.map(_buildChannelChip)
.toList(growable: false),
),
],
),
),
),
SizedBox(height: 8.w),
SCDebounceWidget(
onTap: () {
if (!canConfirm) {
return;
}
_confirmSelection();
},
child: Container(
width: double.infinity,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color:
canConfirm
? SCGlobalConfig.businessLogicStrategy
.getRechargePageButtonBackgroundColor()
: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(10.w),
),
child: Text(
'Confirm',
style: TextStyle(
fontSize: 15.sp,
color:
SCGlobalConfig.businessLogicStrategy
.getRechargePageButtonTextColor(),
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
void _confirmSelection() {
if (widget.packages.isEmpty || widget.channels.isEmpty) {
return;
}
final RechargePackageOption selected = widget.packages.firstWhere(
(RechargePackageOption item) => item.id == _selectedPackageId,
orElse: () => widget.packages.first,
);
final RechargeChannelOption selectedChannel = widget.channels.firstWhere(
(RechargeChannelOption item) => item.code == _selectedChannelCode,
orElse: () => widget.channels.first,
);
widget.onConfirm(selected, selectedChannel);
Navigator.of(context).pop();
}
Widget _buildPackageCard(
RechargePackageOption item,
int index,
bool isSelected,
) {
return SCDebounceWidget(
onTap: () {
setState(() {
_selectedPackageId = item.id;
});
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.w),
border: Border.all(
color:
isSelected ? SocialChatTheme.primaryLight : Colors.transparent,
width: 1.4,
),
),
child: Column(
children: [
SizedBox(
height: 24.w,
child:
item.badge.isEmpty
? null
: Align(
alignment: Alignment.topRight,
child: Container(
margin: EdgeInsets.only(top: 8.w, right: 8.w),
padding: EdgeInsets.symmetric(
horizontal: 7.w,
vertical: 2.w,
),
decoration: BoxDecoration(
color: SocialChatTheme.primaryLight.withValues(
alpha: 0.14,
),
borderRadius: BorderRadius.circular(999.w),
),
child: Text(
item.badge,
style: TextStyle(
fontSize: 9.sp,
color: const Color(0xff03523a),
fontWeight: FontWeight.w700,
),
),
),
),
),
Image.asset(
SCGlobalConfig.businessLogicStrategy
.getRechargePageGoldIconByIndex(index),
width: 38.w,
height: 38.w,
),
SizedBox(height: 6.w),
Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Text(
_formatWholeNumber(item.coins),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
color: Colors.black.withValues(alpha: 0.82),
fontWeight: FontWeight.w700,
),
),
),
SizedBox(height: 2.w),
Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Text(
item.bonusCoins > 0
? '+${_formatWholeNumber(item.bonusCoins)}'
: 'No bonus',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.sp,
color:
item.bonusCoins > 0
? SocialChatTheme.primaryLight
: Colors.black.withValues(alpha: 0.45),
fontWeight: FontWeight.w600,
),
),
),
const Spacer(),
Container(
width: double.infinity,
height: 26.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xffF5C550),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(10.w),
bottomRight: Radius.circular(10.w),
),
),
child: Text(
item.priceLabel,
style: TextStyle(
fontSize: 11.sp,
color: const Color(0xff6F4B00),
fontWeight: FontWeight.w700,
),
),
),
],
),
),
);
}
Widget _buildChannelChip(RechargeChannelOption option) {
final bool isSelected = option.code == _selectedChannelCode;
return SCDebounceWidget(
onTap: () {
setState(() {
_selectedChannelCode = option.code;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
decoration: BoxDecoration(
color:
isSelected
? Colors.white.withValues(alpha: 0.16)
: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8.w),
border: Border.all(
color:
isSelected
? SocialChatTheme.primaryLight
: Colors.white.withValues(alpha: 0.14),
width: 1.1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.name,
style: TextStyle(
fontSize: 13.sp,
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
if (option.typeLabel.isNotEmpty) ...[
SizedBox(height: 2.w),
Text(
option.typeLabel,
style: TextStyle(
fontSize: 10.sp,
color: Colors.white.withValues(alpha: 0.62),
fontWeight: FontWeight.w500,
),
),
],
],
),
),
);
}
Widget _buildEmptyHint(String message) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.w),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10.w),
),
child: Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.68),
fontWeight: FontWeight.w500,
),
),
);
}
String _formatWholeNumber(int value) {
final String raw = value.toString();
final StringBuffer buffer = StringBuffer();
for (int i = 0; i < raw.length; i++) {
final int position = raw.length - i;
buffer.write(raw[i]);
if (position > 1 && position % 3 == 1) {
buffer.write(',');
}
}
return buffer.toString();
}
}

View File

@ -1,6 +1,5 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
@ -9,17 +8,12 @@ import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/constants/sc_screen.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/modules/wallet/recharge/recharge_method_bottom_sheet.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/services/payment/apple_payment_manager.dart';
import 'package:yumi/services/payment/google_payment_manager.dart';
import 'package:yumi/services/payment/mifa_pay_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_mifa_pay_res.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.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/components/text/sc_text.dart';
class RechargePage extends StatefulWidget {
@ -34,16 +28,10 @@ class _RechargePageState extends State<RechargePage> {
static const Color _surfaceTextColor = Colors.white;
static const Color _surfaceSubtleTextColor = Color(0xCCFFFFFF);
late final List<RechargeMethodOption> _methodOptions;
RechargeMethodType _selectedMethodType = RechargeMethodType.nativeStore;
@override
void initState() {
super.initState();
_methodOptions = _buildMethodOptions();
Provider.of<SocialChatUserProfileManager>(context, listen: false).balance();
Provider.of<MiFaPayManager>(context, listen: false).initialize();
if (Platform.isAndroid) {
Provider.of<AndroidPaymentProcessor>(
context,
@ -59,8 +47,6 @@ class _RechargePageState extends State<RechargePage> {
@override
Widget build(BuildContext context) {
final MiFaPayManager miFaPayManager = context.watch<MiFaPayManager>();
return Stack(
children: [
Image.asset(
@ -102,18 +88,10 @@ class _RechargePageState extends State<RechargePage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMethodSection(miFaPayManager),
_buildMethodSection(),
SizedBox(height: 14.w),
Expanded(
child:
_selectedMethodType ==
RechargeMethodType.nativeStore
? _buildNativeProductList()
: _buildMiFaPayContent(miFaPayManager),
),
if (_selectedMethodType ==
RechargeMethodType.nativeStore &&
Platform.isIOS) ...[
Expanded(child: _buildNativeProductList()),
if (Platform.isIOS) ...[
SizedBox(height: 12.w),
_buildFooterButton(
title:
@ -130,19 +108,8 @@ class _RechargePageState extends State<RechargePage> {
],
SizedBox(height: 12.w),
_buildFooterButton(
title:
_selectedMethodType ==
RechargeMethodType.nativeStore
? SCAppLocalizations.of(context)!.recharge
: _buildMiFaPayPrimaryTitle(miFaPayManager),
onTap: () {
if (_selectedMethodType ==
RechargeMethodType.nativeStore) {
_processNativePurchase();
return;
}
_handleMiFaPayPrimaryAction(miFaPayManager);
},
title: SCAppLocalizations.of(context)!.recharge,
onTap: _processNativePurchase,
),
SizedBox(height: 14.w),
],
@ -157,23 +124,6 @@ class _RechargePageState extends State<RechargePage> {
);
}
List<RechargeMethodOption> _buildMethodOptions() {
return <RechargeMethodOption>[
RechargeMethodOption(
type: RechargeMethodType.nativeStore,
title: Platform.isAndroid ? 'Google Pay' : 'Apple Pay',
assetIconPath:
Platform.isAndroid ? 'sc_images/login/sc_icon_google.png' : null,
iconData: Platform.isIOS ? Icons.apple : null,
),
const RechargeMethodOption(
type: RechargeMethodType.miFaPay,
title: 'MiFaPay',
assetIconPath: 'sc_images/index/sc_icon_recharge_agency.png',
),
];
}
void _showRecordDialog() {
showGeneralDialog(
context: context,
@ -288,7 +238,7 @@ class _RechargePageState extends State<RechargePage> {
);
}
Widget _buildMethodSection(MiFaPayManager miFaPayManager) {
Widget _buildMethodSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -301,47 +251,13 @@ class _RechargePageState extends State<RechargePage> {
),
),
SizedBox(height: 10.w),
..._methodOptions.map(
(RechargeMethodOption option) =>
_buildMethodTile(option, miFaPayManager),
),
],
);
}
Widget _buildMethodTile(
RechargeMethodOption option,
MiFaPayManager miFaPayManager,
) {
final bool isSelected = _selectedMethodType == option.type;
String trailingText = '';
if (option.type == RechargeMethodType.miFaPay &&
miFaPayManager.selectedCommodity != null) {
trailingText = _formatMiFaPriceLabel(miFaPayManager.selectedCommodity!);
}
return Padding(
padding: EdgeInsets.only(bottom: 10.w),
child: SCDebounceWidget(
onTap: () {
setState(() {
_selectedMethodType = option.type;
});
if (option.type == RechargeMethodType.miFaPay) {
_openMiFaPaySheet(miFaPayManager);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
Container(
width: double.infinity,
height: 58.w,
padding: EdgeInsets.symmetric(horizontal: 14.w),
decoration: BoxDecoration(
gradient: LinearGradient(
colors:
isSelected
? const [Color(0x42FFFFFF), Color(0x24FFFFFF)]
: const [Color(0x33FFFFFF), Color(0x18FFFFFF)],
gradient: const LinearGradient(
colors: [Color(0x42FFFFFF), Color(0x24FFFFFF)],
begin: AlignmentDirectional.centerStart,
end: AlignmentDirectional.centerEnd,
),
@ -356,11 +272,11 @@ class _RechargePageState extends State<RechargePage> {
),
child: Row(
children: [
_buildMethodLeading(option),
_buildMethodLeading(),
SizedBox(width: 14.w),
Expanded(
child: Text(
option.title,
Platform.isAndroid ? 'Google Pay' : 'Apple Pay',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@ -370,49 +286,28 @@ class _RechargePageState extends State<RechargePage> {
),
),
),
if (trailingText.isNotEmpty)
Padding(
padding: EdgeInsetsDirectional.only(end: 8.w),
child: Text(
trailingText,
style: TextStyle(
fontSize: 12.sp,
color: _surfaceSubtleTextColor.withValues(alpha: 0.82),
fontWeight: FontWeight.w600,
),
),
),
Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_off_outlined,
Icons.radio_button_checked,
size: 18.w,
color:
isSelected
? _accentGoldColor
: _surfaceSubtleTextColor.withValues(alpha: 0.45),
color: _accentGoldColor,
),
],
),
),
),
],
);
}
Widget _buildMethodLeading(RechargeMethodOption option) {
if (option.assetIconPath != null) {
Widget _buildMethodLeading() {
if (Platform.isAndroid) {
return Image.asset(
option.assetIconPath!,
'sc_images/login/sc_icon_google.png',
width: 28.w,
height: 28.w,
fit: BoxFit.contain,
);
}
return Icon(
option.iconData ?? CupertinoIcons.creditcard,
size: 24.w,
color: _surfaceTextColor,
);
return Icon(Icons.apple, size: 24.w, color: _surfaceTextColor);
}
Widget _buildNativeProductList() {
@ -459,122 +354,6 @@ class _RechargePageState extends State<RechargePage> {
);
}
Widget _buildMiFaPayContent(MiFaPayManager miFaPayManager) {
if (miFaPayManager.isLoading && miFaPayManager.commodities.isEmpty) {
return Center(child: CupertinoActivityIndicator(radius: 14.w));
}
if (miFaPayManager.errorMessage.isNotEmpty &&
miFaPayManager.commodities.isEmpty) {
return _buildMiFaPayStatusCard(
title: 'MiFaPay is temporarily unavailable',
detail: miFaPayManager.errorMessage,
actionText: 'Retry',
onTap: () {
miFaPayManager.reload();
},
);
}
if (miFaPayManager.commodities.isEmpty) {
return _buildMiFaPayStatusCard(
title: 'No MiFaPay products available',
detail: 'Please try again later.',
);
}
final SCMiFaPayCommodityItemRes? commodity =
miFaPayManager.selectedCommodity;
final SCMiFaPayChannelRes? channel = miFaPayManager.selectedChannel;
final SCMiFaPayCountryRes? country = miFaPayManager.selectedCountry;
return _buildMiFaPayStatusCard(
title:
commodity == null
? 'Tap MiFaPay to choose an amount'
: 'Selected ${_formatMiFaPriceLabel(commodity)}',
detail:
commodity == null
? 'Choose amount and payment channel in the bottom sheet.'
: '${_formatWholeNumber(_parseWholeNumber(commodity.content))} +${_formatWholeNumber(_parseWholeNumber(commodity.awardContent))}',
actionText:
channel == null
? null
: '${channel.channelName ?? ''}${country?.alphaTwo?.isNotEmpty == true ? ' ${country!.alphaTwo}' : ''}',
);
}
Widget _buildMiFaPayStatusCard({
required String title,
required String detail,
String? actionText,
VoidCallback? onTap,
}) {
return Center(
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.w),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0x33FFFFFF), Color(0x18FFFFFF)],
begin: AlignmentDirectional.topStart,
end: AlignmentDirectional.bottomEnd,
),
borderRadius: BorderRadius.circular(10.w),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10.w,
offset: Offset(0, 3.w),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
color: _surfaceTextColor,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 8.w),
Text(
detail,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color: _surfaceSubtleTextColor,
fontWeight: FontWeight.w500,
),
),
if ((actionText ?? '').isNotEmpty) ...[
SizedBox(height: 10.w),
GestureDetector(
onTap: onTap,
child: Text(
actionText!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color:
onTap != null
? _accentGoldColor
: _surfaceSubtleTextColor.withValues(alpha: 0.82),
fontWeight: FontWeight.w700,
),
),
),
],
],
),
),
);
}
Widget _buildRechargeEmptyState() {
return Center(
child: Column(
@ -641,167 +420,6 @@ class _RechargePageState extends State<RechargePage> {
}
}
String _buildMiFaPayPrimaryTitle(MiFaPayManager miFaPayManager) {
if (miFaPayManager.isCreatingOrder) {
return 'Processing...';
}
if (miFaPayManager.isLoading && miFaPayManager.commodities.isEmpty) {
return 'Loading...';
}
if (miFaPayManager.errorMessage.isNotEmpty &&
miFaPayManager.commodities.isEmpty) {
return 'Retry';
}
return miFaPayManager.canCreateRecharge ? 'Pay now' : 'Choose amount';
}
void _handleMiFaPayPrimaryAction(MiFaPayManager miFaPayManager) {
if (miFaPayManager.isCreatingOrder) {
return;
}
if (miFaPayManager.errorMessage.isNotEmpty &&
miFaPayManager.commodities.isEmpty) {
miFaPayManager.reload();
return;
}
if (miFaPayManager.canCreateRecharge) {
miFaPayManager.createRecharge(context);
return;
}
_openMiFaPaySheet(miFaPayManager);
}
void _openMiFaPaySheet(MiFaPayManager miFaPayManager) {
if (miFaPayManager.isLoading && miFaPayManager.commodities.isEmpty) {
SCTts.show('MiFaPay products are loading');
return;
}
final List<RechargePackageOption> packages = _buildMiFaPackages(
miFaPayManager.commodities,
);
final List<RechargeChannelOption> channels = _buildMiFaChannels(
miFaPayManager.channels,
);
if (packages.isEmpty || channels.isEmpty) {
if (miFaPayManager.errorMessage.isNotEmpty) {
SCTts.show(miFaPayManager.errorMessage);
} else {
SCTts.show('No MiFaPay product is available now');
}
return;
}
showBottomInBottomDialog(
context,
RechargeMethodBottomSheet(
method: _methodOptions.firstWhere(
(RechargeMethodOption item) =>
item.type == RechargeMethodType.miFaPay,
),
packages: packages,
channels: channels,
initialPackageId: miFaPayManager.selectedCommodity?.id,
initialChannelCode: miFaPayManager.selectedChannel?.channelCode,
onConfirm: (
RechargePackageOption package,
RechargeChannelOption channel,
) {
setState(() {
_selectedMethodType = RechargeMethodType.miFaPay;
});
miFaPayManager.chooseCommodityById(package.id);
miFaPayManager.chooseChannelByCode(channel.code);
},
),
barrierColor: Colors.black.withValues(alpha: 0.35),
);
}
List<RechargePackageOption> _buildMiFaPackages(
List<SCMiFaPayCommodityItemRes> commodities,
) {
return commodities
.where((SCMiFaPayCommodityItemRes item) => (item.id ?? '').isNotEmpty)
.map((SCMiFaPayCommodityItemRes item) {
final int coins = _parseWholeNumber(item.content);
final int bonusCoins = _parseWholeNumber(item.awardContent);
return RechargePackageOption(
id: item.id ?? '',
coins: coins,
bonusCoins: bonusCoins,
priceLabel: _formatMiFaPriceLabel(item),
badge: _buildMiFaBadge(coins, bonusCoins),
);
})
.toList(growable: false);
}
List<RechargeChannelOption> _buildMiFaChannels(
List<SCMiFaPayChannelRes> channels,
) {
return channels
.where(
(SCMiFaPayChannelRes item) => (item.channelCode ?? '').isNotEmpty,
)
.map((SCMiFaPayChannelRes item) {
return RechargeChannelOption(
code: item.channelCode ?? '',
name: item.channelName ?? '',
typeLabel: _buildChannelTypeLabel(item),
);
})
.toList(growable: false);
}
String _buildMiFaBadge(int coins, int bonusCoins) {
if (coins <= 0 || bonusCoins <= 0) {
return '';
}
final double percent = bonusCoins * 100 / coins;
return '+${percent.toStringAsFixed(percent >= 10 ? 0 : 1)}%';
}
String _buildChannelTypeLabel(SCMiFaPayChannelRes channel) {
final List<String> parts = <String>[];
if ((channel.channelType ?? '').isNotEmpty) {
parts.add(channel.channelType!);
}
if ((channel.factoryChannel ?? '').isNotEmpty) {
parts.add(channel.factoryChannel!);
}
return parts.join(' ');
}
String _formatMiFaPriceLabel(SCMiFaPayCommodityItemRes item) {
final String currency = item.currency ?? 'USD';
final num amount = item.amount ?? item.amountUsd ?? 0;
final String formattedAmount =
amount % 1 == 0 ? amount.toInt().toString() : amount.toString();
return '$currency $formattedAmount';
}
int _parseWholeNumber(String? value) {
if ((value ?? '').isEmpty) {
return 0;
}
return num.tryParse(value!)?.toInt() ?? 0;
}
String _formatWholeNumber(num value) {
final String raw = value.toInt().toString();
final StringBuffer buffer = StringBuffer();
for (int i = 0; i < raw.length; i++) {
final int position = raw.length - i;
buffer.write(raw[i]);
if (position > 1 && position % 3 == 1) {
buffer.write(',');
}
}
return buffer.toString();
}
Widget _buildGoogleProductItem(
SelecteProductConfig productConfig,
AndroidPaymentProcessor ref,

View File

@ -1,451 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/modules/wallet/recharge/mifa_pay_webview_page.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_mifa_pay_res.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
class MiFaPayManager extends ChangeNotifier {
static const String defaultApplicationId = '2048200000000000701';
static const String defaultPayCountryId = '2048200000000000401';
static const String defaultPayCountryName = 'Saudi Arabia';
static const String defaultPayCountryCode = 'SA';
bool _initialized = false;
bool _isLoading = false;
bool _isCreatingOrder = false;
String _errorMessage = '';
String _applicationId = defaultApplicationId;
List<SCMiFaPayCountryRes> _countries = <SCMiFaPayCountryRes>[];
SCMiFaPayCountryRes? _selectedCountry;
SCMiFaPayCommodityRes? _commodityRes;
SCMiFaPayCommodityItemRes? _selectedCommodity;
SCMiFaPayChannelRes? _selectedChannel;
bool get initialized => _initialized;
bool get isLoading => _isLoading;
bool get isCreatingOrder => _isCreatingOrder;
String get errorMessage => _errorMessage;
String get applicationId => _applicationId;
List<SCMiFaPayCountryRes> get countries => _countries;
SCMiFaPayCountryRes? get selectedCountry => _selectedCountry;
SCMiFaPayCommodityRes? get commodityRes => _commodityRes;
List<SCMiFaPayCommodityItemRes> get commodities =>
_commodityRes?.commodity ?? <SCMiFaPayCommodityItemRes>[];
List<SCMiFaPayChannelRes> get channels =>
_commodityRes?.channels ?? <SCMiFaPayChannelRes>[];
SCMiFaPayCommodityItemRes? get selectedCommodity => _selectedCommodity;
SCMiFaPayChannelRes? get selectedChannel => _selectedChannel;
bool get canCreateRecharge =>
_selectedCountry != null &&
_selectedCommodity != null &&
_selectedChannel != null &&
!_isLoading &&
!_isCreatingOrder;
Future<void> initialize({bool force = false}) async {
if (_isLoading) {
return;
}
if (_initialized && !force) {
return;
}
_isLoading = true;
_errorMessage = '';
notifyListeners();
try {
await _loadCountries();
await _loadCommodity();
_initialized = true;
} catch (error) {
_errorMessage = _readErrorMessage(error);
debugPrint('MiFaPay initialize failed: $error');
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> reload() async {
await initialize(force: true);
}
void chooseCommodityById(String goodsId) {
if (goodsId.isEmpty) {
return;
}
for (final SCMiFaPayCommodityItemRes item in commodities) {
if (item.id == goodsId) {
_selectedCommodity = item;
notifyListeners();
return;
}
}
}
void chooseChannelByCode(String channelCode) {
if (channelCode.isEmpty) {
return;
}
for (final SCMiFaPayChannelRes item in channels) {
if (item.channelCode == channelCode) {
_selectedChannel = item;
notifyListeners();
return;
}
}
}
Future<void> createRecharge(BuildContext context) async {
final SCMiFaPayCountryRes? country = _selectedCountry;
final SCMiFaPayCommodityItemRes? commodity = _selectedCommodity;
final SCMiFaPayChannelRes? channel = _selectedChannel;
final String userId =
AccountStorage().getCurrentUser()?.userProfile?.id ?? "";
final NavigatorState navigator = Navigator.of(context);
final String purchaseSuccessMessage =
SCAppLocalizations.of(context)!.purchaseIsSuccessful;
const String paymentPendingMessage = 'Payment confirmation in progress';
final Map<String, dynamic> payload = <String, dynamic>{
"applicationId": _applicationId,
"goodsId": commodity?.id ?? '',
"payCountryId": country?.id ?? '',
"userId": userId,
"channelCode": channel?.channelCode ?? '',
"newVersion": true,
};
if (country?.id?.isNotEmpty != true ||
commodity?.id?.isNotEmpty != true ||
channel?.channelCode?.isNotEmpty != true) {
SCTts.show('Please select MiFaPay amount and channel first');
return;
}
if (userId.isEmpty) {
SCTts.show('User id is empty');
return;
}
if (_isCreatingOrder) {
return;
}
final SocialChatUserProfileManager profileManager =
Provider.of<SocialChatUserProfileManager>(context, listen: false);
final double balanceBeforeRecharge = profileManager.myBalance;
try {
_isCreatingOrder = true;
notifyListeners();
SCLoadingManager.show(context: context);
_logRequest(
method: 'POST',
path: '/order/web/pay/recharge',
body: payload,
);
final SCMiFaPayRechargeRes res = await SCConfigRepositoryImp()
.mifaPayRecharge(
applicationId: _applicationId,
goodsId: commodity!.id!,
payCountryId: country!.id!,
userId: userId,
channelCode: channel!.channelCode!,
);
SCLoadingManager.hide();
_logResponse(tag: 'recharge', data: res.toJson());
if ((res.requestUrl ?? "").isEmpty) {
SCTts.show('MiFaPay requestUrl is empty');
return;
}
await navigator.push(
MaterialPageRoute<void>(
builder:
(_) => MiFaPayWebViewPage(
title: 'MiFaPay',
requestUrl: res.requestUrl!,
),
),
);
final bool rechargeSucceeded = await _waitForRechargeResult(
profileManager,
balanceBeforeRecharge,
);
SCTts.show(
rechargeSucceeded ? purchaseSuccessMessage : paymentPendingMessage,
);
} catch (error) {
_logError(
tag: 'recharge',
path: '/order/web/pay/recharge',
error: error,
body: payload,
);
final String message = _readErrorMessage(error);
SCTts.show(message);
debugPrint('MiFaPay createRecharge failed: $error');
} finally {
SCLoadingManager.hide();
_isCreatingOrder = false;
notifyListeners();
}
}
Future<void> _loadCountries() async {
List<SCMiFaPayCountryRes> countries = <SCMiFaPayCountryRes>[];
try {
_logRequest(method: 'GET', path: '/order/web/pay/country');
countries = await SCConfigRepositoryImp().mifaPayCountries();
_logResponse(
tag: 'country',
data:
countries.map((SCMiFaPayCountryRes item) => item.toJson()).toList(),
);
} catch (error) {
_logError(tag: 'country', path: '/order/web/pay/country', error: error);
debugPrint('MiFaPay countries fallback: $error');
}
if (countries.isEmpty) {
countries = <SCMiFaPayCountryRes>[_fallbackCountry()];
}
_countries = countries;
final String currentCountryId = _selectedCountry?.id ?? '';
_selectedCountry = countries.firstWhere(
(SCMiFaPayCountryRes item) => item.id == currentCountryId,
orElse: () => countries.first,
);
}
Future<void> _loadCommodity() async {
final String payCountryId =
_selectedCountry?.id?.isNotEmpty == true
? _selectedCountry!.id!
: defaultPayCountryId;
final Map<String, dynamic> queryParams = <String, dynamic>{
"applicationId": _applicationId,
"payCountryId": payCountryId,
"type": "GOLD",
};
try {
_logRequest(
method: 'GET',
path: '/order/web/pay/commodity',
queryParams: queryParams,
);
final SCMiFaPayCommodityRes res = await SCConfigRepositoryImp()
.mifaPayCommodity(
applicationId: _applicationId,
payCountryId: payCountryId,
);
_logResponse(tag: 'commodity', data: res.toJson());
_commodityRes = res;
_applicationId = res.application?.id ?? _applicationId;
final List<SCMiFaPayCommodityItemRes> commodityList = commodities;
final String currentGoodsId = _selectedCommodity?.id ?? '';
_selectedCommodity =
commodityList.isEmpty
? null
: commodityList.firstWhere(
(SCMiFaPayCommodityItemRes item) => item.id == currentGoodsId,
orElse: () => commodityList.first,
);
final List<SCMiFaPayChannelRes> channelList = channels;
final String currentChannelCode = _selectedChannel?.channelCode ?? '';
_selectedChannel =
channelList.isEmpty
? null
: channelList.firstWhere(
(SCMiFaPayChannelRes item) =>
item.channelCode == currentChannelCode,
orElse: () => channelList.first,
);
} catch (error) {
_logError(
tag: 'commodity',
path: '/order/web/pay/commodity',
error: error,
queryParams: queryParams,
);
rethrow;
}
}
Future<void> _refreshWalletState(
SocialChatUserProfileManager profileManager,
) async {
try {
final double balance = await SCAccountRepository().balance();
profileManager.updateBalance(balance);
} catch (error) {
debugPrint('MiFaPay balance refresh failed: $error');
}
try {
await profileManager.fetchUserProfileData(loadGuardCount: false);
} catch (error) {
debugPrint('MiFaPay profile refresh failed: $error');
}
}
Future<void> _pollWalletState(
SocialChatUserProfileManager profileManager,
) async {
for (int index = 0; index < 2; index++) {
await Future<void>.delayed(const Duration(seconds: 3));
await _refreshWalletState(profileManager);
}
}
Future<bool> _waitForRechargeResult(
SocialChatUserProfileManager profileManager,
double previousBalance,
) async {
await _refreshWalletState(profileManager);
if (profileManager.myBalance > previousBalance) {
return true;
}
for (int index = 0; index < 5; index++) {
await Future<void>.delayed(const Duration(seconds: 2));
await _refreshWalletState(profileManager);
if (profileManager.myBalance > previousBalance) {
return true;
}
}
unawaited(_pollWalletState(profileManager));
return false;
}
SCMiFaPayCountryRes _fallbackCountry() {
return SCMiFaPayCountryRes(
id: defaultPayCountryId,
countryName: defaultPayCountryName,
alphaTwo: defaultPayCountryCode,
nationalFlag: '',
);
}
String _readErrorMessage(Object error) {
final String raw = error.toString();
return raw
.replaceFirst('Exception: ', '')
.replaceFirst('DioException [unknown]: ', '')
.trim();
}
void _logRequest({
required String method,
required String path,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? body,
}) {
debugPrint('[MiFaPay][Request] $method ${_buildUrl(path)}');
if (queryParams != null && queryParams.isNotEmpty) {
debugPrint('[MiFaPay][Query] ${_safeJson(queryParams)}');
}
if (body != null && body.isNotEmpty) {
debugPrint('[MiFaPay][Body] ${_safeJson(body)}');
}
}
void _logResponse({required String tag, required dynamic data}) {
debugPrint('[MiFaPay][Response][$tag] ${_safeJson(data)}');
}
void _logError({
required String tag,
required String path,
required Object error,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? body,
}) {
debugPrint('[MiFaPay][Error][$tag] ${_buildUrl(path)}');
if (queryParams != null && queryParams.isNotEmpty) {
debugPrint('[MiFaPay][ErrorQuery][$tag] ${_safeJson(queryParams)}');
}
if (body != null && body.isNotEmpty) {
debugPrint('[MiFaPay][ErrorBody][$tag] ${_safeJson(body)}');
}
if (error is DioException) {
debugPrint('[MiFaPay][Dio][$tag] type=${error.type}');
debugPrint(
'[MiFaPay][Dio][$tag] status=${error.response?.statusCode} message=${error.message}',
);
if (error.requestOptions.path.isNotEmpty) {
debugPrint(
'[MiFaPay][Dio][$tag] requestPath=${error.requestOptions.path}',
);
}
if (error.requestOptions.queryParameters.isNotEmpty) {
debugPrint(
'[MiFaPay][Dio][$tag] requestQuery=${_safeJson(error.requestOptions.queryParameters)}',
);
}
if (error.requestOptions.data != null) {
debugPrint(
'[MiFaPay][Dio][$tag] requestData=${_safeJson(error.requestOptions.data)}',
);
}
if (error.response?.data != null) {
debugPrint(
'[MiFaPay][Dio][$tag] responseData=${_safeJson(error.response?.data)}',
);
}
} else {
debugPrint('[MiFaPay][Error][$tag] $error');
}
}
String _buildUrl(String path) {
final String host = SCGlobalConfig.apiHost;
if (host.endsWith('/') && path.startsWith('/')) {
return '${host.substring(0, host.length - 1)}$path';
}
if (!host.endsWith('/') && !path.startsWith('/')) {
return '$host/$path';
}
return '$host$path';
}
String _safeJson(dynamic data) {
try {
return jsonEncode(data);
} catch (_) {
return data.toString();
}
}
}

View File

@ -298,6 +298,42 @@ class SCMiFaPayRechargeRes {
}
}
class SCMiFaPayOrderStatusRes {
SCMiFaPayOrderStatusRes({bool? paid, String? statusText, dynamic rawBody}) {
_paid = paid;
_statusText = statusText;
_rawBody = rawBody;
}
SCMiFaPayOrderStatusRes.fromJson(dynamic json) {
_rawBody = json;
_paid = _resolvePaid(json);
_statusText = _resolveStatusText(json);
}
bool? _paid;
String? _statusText;
dynamic _rawBody;
bool get isPaid => _paid == true;
String? get statusText => _statusText;
dynamic get rawBody => _rawBody;
Map<String, dynamic> toJson() {
final dynamic body = _rawBody;
if (body is Map<String, dynamic>) {
return body;
}
return <String, dynamic>{
'paid': _paid,
'statusText': _statusText,
'rawBody': body,
};
}
}
String? _stringValue(dynamic value) {
if (value == null) {
return null;
@ -315,3 +351,102 @@ num? _numValue(dynamic value) {
}
return num.tryParse(value.toString());
}
bool? _boolValue(dynamic value) {
if (value == null) {
return null;
}
if (value is bool) {
return value;
}
if (value is num) {
return value != 0;
}
final String normalized = value.toString().trim().toLowerCase();
if (<String>[
'true',
'success',
'succeeded',
'paid',
'done',
'completed',
].contains(normalized)) {
return true;
}
if (<String>[
'false',
'pending',
'processing',
'failed',
'cancelled',
].contains(normalized)) {
return false;
}
return null;
}
bool? _resolvePaid(dynamic json) {
final bool? direct = _boolValue(json);
if (direct != null) {
return direct;
}
if (json is Map) {
const List<String> boolKeys = <String>[
'paid',
'success',
'paySuccess',
'orderPaid',
'finished',
'completed',
'result',
];
for (final String key in boolKeys) {
final bool? value = _boolValue(json[key]);
if (value != null) {
return value;
}
}
const List<String> statusKeys = <String>[
'status',
'orderStatus',
'payStatus',
'tradeStatus',
];
for (final String key in statusKeys) {
final bool? value = _boolValue(json[key]);
if (value != null) {
return value;
}
}
}
return null;
}
String? _resolveStatusText(dynamic json) {
if (json == null) {
return null;
}
if (json is String) {
return json;
}
if (json is Map) {
const List<String> keys = <String>[
'status',
'orderStatus',
'payStatus',
'tradeStatus',
'message',
'msg',
];
for (final String key in keys) {
final String? value = _stringValue(json[key]);
if (value != null) {
return value;
}
}
}
return _stringValue(json);
}

View File

@ -0,0 +1,211 @@
Map<String, dynamic> _asMap(dynamic json) {
if (json is Map<String, dynamic>) {
return json;
}
return <String, dynamic>{};
}
List<dynamic> _asList(dynamic json) {
if (json is List) {
return json;
}
return const <dynamic>[];
}
String _asString(dynamic value) {
if (value == null) {
return '';
}
return value.toString();
}
int _asInt(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(_asString(value)) ?? 0;
}
bool _asBool(dynamic value) {
if (value is bool) {
return value;
}
if (value is num) {
return value != 0;
}
final normalized = _asString(value).trim().toLowerCase();
return normalized == 'true' || normalized == '1';
}
class SCSignInRewardItem {
const SCSignInRewardItem({
required this.id,
required this.type,
required this.name,
required this.content,
required this.quantity,
required this.cover,
required this.remark,
});
final String id;
final String type;
final String name;
final String content;
final int quantity;
final String cover;
final String remark;
factory SCSignInRewardItem.fromJson(dynamic json) {
final map = _asMap(json);
return SCSignInRewardItem(
id: _asString(map['id']),
type: _asString(map['type']),
name: _asString(map['name']),
content: _asString(map['content']),
quantity: _asInt(map['quantity']),
cover: _asString(map['cover']),
remark: _asString(map['remark']),
);
}
}
class SCSignInRewardDay {
const SCSignInRewardDay({
required this.dayIndex,
required this.rewardGroupId,
required this.rewardGroupName,
required this.rewardItems,
required this.signed,
required this.todayTarget,
});
final int dayIndex;
final String rewardGroupId;
final String rewardGroupName;
final List<SCSignInRewardItem> rewardItems;
final bool signed;
final bool todayTarget;
factory SCSignInRewardDay.fromJson(dynamic json) {
final map = _asMap(json);
return SCSignInRewardDay(
dayIndex: _asInt(map['dayIndex']),
rewardGroupId: _asString(map['rewardGroupId']),
rewardGroupName: _asString(map['rewardGroupName']),
rewardItems:
_asList(
map['rewardItems'],
).map((item) => SCSignInRewardItem.fromJson(item)).toList(),
signed: _asBool(map['signed']),
todayTarget: _asBool(map['todayTarget']),
);
}
}
class SCSignInRewardStatusRes {
const SCSignInRewardStatusRes({
required this.userId,
required this.sysOrigin,
required this.configured,
required this.enabled,
required this.available,
required this.timezone,
required this.cycleDays,
required this.currentDate,
required this.signedToday,
required this.currentStreak,
required this.nextDayIndex,
required this.days,
});
final String userId;
final String sysOrigin;
final bool configured;
final bool enabled;
final bool available;
final String timezone;
final int cycleDays;
final String currentDate;
final bool signedToday;
final int currentStreak;
final int nextDayIndex;
final List<SCSignInRewardDay> days;
bool get canShowDialog =>
configured && enabled && available && !signedToday && days.isNotEmpty;
factory SCSignInRewardStatusRes.fromJson(dynamic json) {
final map = _asMap(json);
return SCSignInRewardStatusRes(
userId: _asString(map['userId']),
sysOrigin: _asString(map['sysOrigin']),
configured: _asBool(map['configured']),
enabled: _asBool(map['enabled']),
available: _asBool(map['available']),
timezone: _asString(map['timezone']),
cycleDays: _asInt(map['cycleDays']),
currentDate: _asString(map['currentDate']),
signedToday: _asBool(map['signedToday']),
currentStreak: _asInt(map['currentStreak']),
nextDayIndex: _asInt(map['nextDayIndex']),
days:
_asList(
map['days'],
).map((day) => SCSignInRewardDay.fromJson(day)).toList(),
);
}
}
class SCSignInRewardCheckInRes {
const SCSignInRewardCheckInRes({
required this.success,
required this.alreadySigned,
required this.userId,
required this.sysOrigin,
required this.claimDate,
required this.dayIndex,
required this.streakCount,
required this.rewardGroupId,
required this.rewardGroupName,
required this.rewardItems,
required this.status,
});
final bool success;
final bool alreadySigned;
final String userId;
final String sysOrigin;
final String claimDate;
final int dayIndex;
final int streakCount;
final String rewardGroupId;
final String rewardGroupName;
final List<SCSignInRewardItem> rewardItems;
final String status;
bool get isClaimed => success || alreadySigned;
factory SCSignInRewardCheckInRes.fromJson(dynamic json) {
final map = _asMap(json);
return SCSignInRewardCheckInRes(
success: _asBool(map['success']),
alreadySigned: _asBool(map['alreadySigned']),
userId: _asString(map['userId']),
sysOrigin: _asString(map['sysOrigin']),
claimDate: _asString(map['claimDate']),
dayIndex: _asInt(map['dayIndex']),
streakCount: _asInt(map['streakCount']),
rewardGroupId: _asString(map['rewardGroupId']),
rewardGroupName: _asString(map['rewardGroupName']),
rewardItems:
_asList(
map['rewardItems'],
).map((item) => SCSignInRewardItem.fromJson(item)).toList(),
status: _asString(map['status']),
);
}
}

View File

@ -77,6 +77,9 @@ abstract class SocialChatConfigRepository {
required String channelCode,
});
///MiFaPay
Future<SCMiFaPayOrderStatusRes> mifaPayOrderStatus({required String orderId});
///
Future<SCLevelConfigRes> configLevel();

View File

@ -1,9 +1,12 @@
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart' hide WearBadge;
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart'
hide WearBadge;
import 'package:yumi/shared/business_logic/models/req/sc_mobile_auth_cmd.dart';
import 'package:yumi/shared/business_logic/models/res/sc_edit_room_info_res.dart';
import 'package:yumi/shared/business_logic/models/res/follow_room_res.dart' hide WearBadge;
import 'package:yumi/shared/business_logic/models/res/follow_user_res.dart' hide WearBadge;
import 'package:yumi/shared/business_logic/models/res/follow_room_res.dart'
hide WearBadge;
import 'package:yumi/shared/business_logic/models/res/follow_user_res.dart'
hide WearBadge;
import 'package:yumi/shared/business_logic/models/res/sc_gold_record_res.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/business_logic/models/res/message_friend_user_res.dart';
@ -12,9 +15,11 @@ import 'package:yumi/shared/business_logic/models/res/sc_prop_coupon_list_res.da
import 'package:yumi/shared/business_logic/models/res/sc_prop_coupon_record_list_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_public_message_page_res.dart';
import 'package:yumi/shared/business_logic/models/res/room_black_list_res.dart';
import 'package:yumi/shared/business_logic/models/res/room_res.dart' hide WearBadge;
import 'package:yumi/shared/business_logic/models/res/room_res.dart'
hide WearBadge;
import 'package:yumi/shared/business_logic/models/req/sc_user_profile_cmd.dart';
import 'package:yumi/shared/business_logic/models/res/sc_rtc_token_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_reward_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_user_counter_res.dart';
@ -146,7 +151,6 @@ abstract class SocialChatUserRepository {
/// SUBSCRIPTION:FANS:FRIEND:,访.
Future<List<SCUserCounterRes>> userCounter(String userId);
///
Future<SCUserLevelExpRes> userLevelConsumptionExp(String userId, String type);
@ -159,6 +163,12 @@ abstract class SocialChatUserRepository {
///
Future<int> checkInReceive(String id, String resourceGroupId);
///
Future<SCSignInRewardStatusRes> signInRewardStatus();
///
Future<SCSignInRewardCheckInRes> signInRewardCheckIn();
///
Future<bool> teamCreate(String ownUserId);
@ -226,7 +236,6 @@ abstract class SocialChatUserRepository {
///
Future<bool> couponSend(String couponNo, String receiverId);
///
Future<SCViolationHandleRes> userViolationHandle(
String userId,
@ -236,7 +245,6 @@ abstract class SocialChatUserRepository {
List<String>? imageUrls,
});
///
Future<bool> friendRelationCheck(String userId);
@ -269,5 +277,4 @@ abstract class SocialChatUserRepository {
///
Future<bool> logoutAccount();
}

View File

@ -242,6 +242,19 @@ class SCConfigRepositoryImp implements SocialChatConfigRepository {
return result;
}
///order/web/pay/order-status
@override
Future<SCMiFaPayOrderStatusRes> mifaPayOrderStatus({
required String orderId,
}) async {
final result = await http.get<SCMiFaPayOrderStatusRes>(
"/order/web/pay/order-status",
queryParams: {"orderId": orderId},
fromJson: (json) => SCMiFaPayOrderStatusRes.fromJson(json),
);
return result;
}
///sys/static-config/level
@override
Future<SCLevelConfigRes> configLevel() async {

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:yumi/shared/business_logic/models/req/sc_user_profile_cmd.dart';
import 'package:yumi/shared/business_logic/models/res/sc_gold_record_res.dart';
@ -25,6 +28,7 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/business_logic/models/res/message_friend_user_res.dart';
import 'package:yumi/shared/business_logic/models/res/my_room_res.dart';
import 'package:yumi/shared/business_logic/models/res/room_black_list_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_reward_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_violation_handle_res.dart';
import 'package:yumi/shared/business_logic/repositories/user_repository.dart';
@ -614,6 +618,154 @@ class SCAccountRepository implements SocialChatUserRepository {
return result;
}
@override
Future<SCSignInRewardStatusRes> signInRewardStatus() async {
const path = "/go/app/sign-in-reward/status";
debugPrint(
'[SignInReward][Request] GET $path '
'baseUrl=${http.dio.options.baseUrl} params={}',
);
try {
final result = await http.get<SCSignInRewardStatusRes>(
path,
fromJson: (json) => SCSignInRewardStatusRes.fromJson(json),
);
debugPrint(
'[SignInReward][Response] GET $path '
'summary=${jsonEncode(_signInRewardStatusSummary(result))}',
);
return result;
} catch (error) {
debugPrint('[SignInReward][Error] GET $path error=$error');
_debugSignInRewardError('GET', path, error);
rethrow;
}
}
@override
Future<SCSignInRewardCheckInRes> signInRewardCheckIn() async {
const path = "/go/app/sign-in-reward/check-in";
const body = <String, dynamic>{};
debugPrint(
'[SignInReward][Request] POST $path '
'baseUrl=${http.dio.options.baseUrl} body=${jsonEncode(body)}',
);
try {
final result = await http.post<SCSignInRewardCheckInRes>(
path,
data: body,
fromJson: (json) => SCSignInRewardCheckInRes.fromJson(json),
);
debugPrint(
'[SignInReward][Response] POST $path '
'summary=${jsonEncode(_signInRewardCheckInSummary(result))}',
);
return result;
} catch (error) {
debugPrint('[SignInReward][Error] POST $path error=$error');
_debugSignInRewardError('POST', path, error);
rethrow;
}
}
void _debugSignInRewardError(String method, String path, Object error) {
if (error is DioException) {
final requestOptions = error.requestOptions;
final response = error.response;
debugPrint(
'[SignInReward][Dio] $method $path '
'type=${error.type} '
'message=${error.message} '
'uri=${requestOptions.uri}',
);
debugPrint(
'[SignInReward][Dio] $method $path '
'headers=${jsonEncode(requestOptions.headers)} '
'query=${jsonEncode(requestOptions.queryParameters)} '
'data=${jsonEncode(requestOptions.data)}',
);
debugPrint(
'[SignInReward][Dio] $method $path '
'statusCode=${response?.statusCode} '
'statusMessage=${response?.statusMessage} '
'response=${jsonEncode(response?.data)} '
'innerError=${error.error}',
);
return;
}
debugPrint('[SignInReward][Dio] $method $path non-dio-error=$error');
}
Map<String, dynamic> _signInRewardStatusSummary(
SCSignInRewardStatusRes result,
) {
return <String, dynamic>{
'configured': result.configured,
'enabled': result.enabled,
'available': result.available,
'signedToday': result.signedToday,
'currentStreak': result.currentStreak,
'nextDayIndex': result.nextDayIndex,
'cycleDays': result.cycleDays,
'daysCount': result.days.length,
'days':
result.days
.map(
(day) => <String, dynamic>{
'dayIndex': day.dayIndex,
'signed': day.signed,
'todayTarget': day.todayTarget,
'rewardGroupId': day.rewardGroupId,
'rewardGroupName': day.rewardGroupName,
'rewardItems':
day.rewardItems
.map(
(item) => <String, dynamic>{
'id': item.id,
'type': item.type,
'name': item.name,
'content': item.content,
'quantity': item.quantity,
'cover': item.cover,
'remark': item.remark,
},
)
.toList(),
},
)
.toList(),
};
}
Map<String, dynamic> _signInRewardCheckInSummary(
SCSignInRewardCheckInRes result,
) {
return <String, dynamic>{
'success': result.success,
'alreadySigned': result.alreadySigned,
'dayIndex': result.dayIndex,
'streakCount': result.streakCount,
'rewardGroupId': result.rewardGroupId,
'rewardGroupName': result.rewardGroupName,
'status': result.status,
'rewardItems':
result.rewardItems
.map(
(item) => <String, dynamic>{
'id': item.id,
'type': item.type,
'name': item.name,
'content': item.content,
'quantity': item.quantity,
'cover': item.cover,
'remark': item.remark,
},
)
.toList(),
};
}
///team/create
@override
Future<bool> teamCreate(String ownUserId) async {

View File

@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
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/shared/business_logic/models/res/sc_sign_in_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_reward_res.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
@ -22,8 +22,6 @@ class DailySignInDialogItem {
required this.isFinalDay,
this.subtitle = '',
this.cover = '',
this.rewardId = '',
this.resourceGroupId = '',
});
final int day;
@ -32,13 +30,8 @@ class DailySignInDialogItem {
final String cover;
final DailySignInRewardStatus status;
final bool isFinalDay;
final String rewardId;
final String resourceGroupId;
bool get canClaim =>
status == DailySignInRewardStatus.current &&
rewardId.isNotEmpty &&
resourceGroupId.isNotEmpty;
bool get canClaim => status == DailySignInRewardStatus.current;
DailySignInDialogItem copyWith({
int? day,
@ -47,8 +40,6 @@ class DailySignInDialogItem {
String? cover,
DailySignInRewardStatus? status,
bool? isFinalDay,
String? rewardId,
String? resourceGroupId,
}) {
return DailySignInDialogItem(
day: day ?? this.day,
@ -57,8 +48,6 @@ class DailySignInDialogItem {
cover: cover ?? this.cover,
status: status ?? this.status,
isFinalDay: isFinalDay ?? this.isFinalDay,
rewardId: rewardId ?? this.rewardId,
resourceGroupId: resourceGroupId ?? this.resourceGroupId,
);
}
}
@ -132,42 +121,41 @@ class DailySignInDialogData {
);
}
factory DailySignInDialogData.fromLegacy({
required SCSignInRes signInRes,
required bool checkedToday,
factory DailySignInDialogData.fromStatus({
required SCSignInRewardStatusRes statusRes,
}) {
final rewards = List<Rewards>.from(
signInRes.rewards ?? const <Rewards>[],
)..sort(
(left, right) => (left.rule?.sort ?? 0).compareTo(right.rule?.sort ?? 0),
);
final signedDays = (signInRes.days ?? 0).toInt().clamp(0, 7);
final currentDay = checkedToday ? -1 : math.min(signedDays + 1, 7);
final sortedDays = List<SCSignInRewardDay>.from(statusRes.days)
..sort((left, right) => left.dayIndex.compareTo(right.dayIndex));
final dayMap = <int, SCSignInRewardDay>{
for (final day in sortedDays) day.dayIndex: day,
};
final items = List<DailySignInDialogItem>.generate(7, (index) {
final day = index + 1;
final reward = index < rewards.length ? rewards[index] : null;
final rewardDay = dayMap[day];
final status =
day <= signedDays
rewardDay?.signed == true
? DailySignInRewardStatus.claimed
: day == currentDay
: !statusRes.signedToday &&
((rewardDay?.todayTarget ?? false) ||
statusRes.nextDayIndex == day)
? DailySignInRewardStatus.current
: DailySignInRewardStatus.pending;
return DailySignInDialogItem(
day: day,
title: _resolveTitle(reward),
subtitle: _resolveSubtitle(reward),
cover: _resolveCover(reward),
title: _resolveTitle(rewardDay),
subtitle: _resolveSubtitle(rewardDay),
cover: _resolveCover(rewardDay),
status: status,
isFinalDay: day == 7,
rewardId: reward?.rule?.id?.trim() ?? '',
resourceGroupId: reward?.rule?.resourceGroupId?.trim() ?? '',
);
});
return DailySignInDialogData(items: items, checkedToday: checkedToday);
return DailySignInDialogData(
items: items,
checkedToday: statusRes.signedToday,
);
}
DailySignInDialogData copyWith({
@ -180,12 +168,12 @@ class DailySignInDialogData {
);
}
static ActivityRewardProps? _firstRewardProp(Rewards? reward) {
final props = reward?.propsGroup?.activityRewardProps;
if (props == null || props.isEmpty) {
static SCSignInRewardItem? _firstRewardItem(SCSignInRewardDay? rewardDay) {
final rewardItems = rewardDay?.rewardItems;
if (rewardItems == null || rewardItems.isEmpty) {
return null;
}
return props.first;
return rewardItems.first;
}
static String _pickFirstNonEmpty(List<String?> values, String fallback) {
@ -198,38 +186,32 @@ class DailySignInDialogData {
return fallback;
}
static String _resolveTitle(Rewards? reward) {
final prop = _firstRewardProp(reward);
static String _resolveTitle(SCSignInRewardDay? rewardDay) {
final rewardItem = _firstRewardItem(rewardDay);
return _pickFirstNonEmpty([
reward?.propsGroup?.name,
prop?.content,
prop?.remark,
prop?.detailType,
rewardItem?.name,
rewardItem?.content,
rewardDay?.rewardGroupName,
rewardItem?.type,
rewardItem?.remark,
], 'Profile Frame');
}
static String _resolveSubtitle(Rewards? reward) {
final prop = _firstRewardProp(reward);
final quantity = prop?.quantity;
if (quantity != null && quantity > 1) {
return 'x${quantity.toInt()}';
}
final amount = prop?.amount;
if (amount != null && amount > 0) {
return amount == amount.toInt() ? '${amount.toInt()}' : '$amount';
static String _resolveSubtitle(SCSignInRewardDay? rewardDay) {
final rewardItem = _firstRewardItem(rewardDay);
if ((rewardItem?.quantity ?? 0) > 1) {
return 'x${rewardItem!.quantity}';
}
final textValue = _pickFirstNonEmpty([
prop?.type,
prop?.detailType,
prop?.remark,
rewardItem?.remark,
rewardItem?.type,
], '');
return textValue == 'Profile Frame' ? '' : textValue;
}
static String _resolveCover(Rewards? reward) {
return _firstRewardProp(reward)?.cover?.trim() ?? '';
static String _resolveCover(SCSignInRewardDay? rewardDay) {
return _firstRewardItem(rewardDay)?.cover.trim() ?? '';
}
}
@ -277,12 +259,19 @@ class _DailySignInDialogState extends State<DailySignInDialog> {
void initState() {
super.initState();
_data = widget.data;
debugPrint(
'[SignInReward][Dialog] init '
'checkedToday=${_data.checkedToday} '
'currentDay=${_data.currentItem?.day ?? 0} '
'items=${_debugItemsSummary(_data.items)}',
);
}
Future<void> _handleCheckIn() async {
if (_isSubmitting) {
return;
}
final l10n = SCAppLocalizations.of(context)!;
if (_data.checkedToday) {
SmartDialog.dismiss(tag: DailySignInDialog.dialogTag);
return;
@ -298,25 +287,28 @@ class _DailySignInDialogState extends State<DailySignInDialog> {
_isSubmitting = true;
});
SCLoadingManager.show();
debugPrint(
'[SignInReward][Dialog] tap check-in '
'currentDay=${currentItem.day}',
);
try {
await SCAccountRepository().checkInReceive(
currentItem.rewardId,
currentItem.resourceGroupId,
);
final checkInResult = await SCAccountRepository().signInRewardCheckIn();
if (!checkInResult.isClaimed) {
throw Exception(l10n.thisFeatureIsCurrentlyUnavailable);
}
DailySignInDialogData latestData;
try {
final result = await Future.wait<dynamic>([
SCAccountRepository().checkInToday(),
SCAccountRepository().sginListAward(),
]);
latestData = DailySignInDialogData.fromLegacy(
signInRes: result[1] as SCSignInRes,
checkedToday: result[0] as bool,
);
debugPrint('[SignInReward][Dialog] reload status after check-in');
final latestStatus = await SCAccountRepository().signInRewardStatus();
latestData = DailySignInDialogData.fromStatus(statusRes: latestStatus);
} catch (_) {
latestData = _markCurrentItemAsClaimed(_data);
debugPrint(
'[SignInReward][Dialog] reload status failed, fallback to local update '
'dayIndex=${checkInResult.dayIndex}',
);
latestData = _markDayAsClaimed(_data, checkInResult.dayIndex);
}
SCLoadingManager.hide();
@ -326,9 +318,19 @@ class _DailySignInDialogState extends State<DailySignInDialog> {
setState(() {
_data = latestData;
});
SCTts.show(SCAppLocalizations.of(context)!.receiveSucc);
debugPrint(
'[SignInReward][Dialog] check-in success '
'alreadySigned=${checkInResult.alreadySigned} '
'dayIndex=${checkInResult.dayIndex} '
'checkedToday=${latestData.checkedToday}',
);
SCTts.show(
checkInResult.alreadySigned ? l10n.signedin : l10n.receiveSucc,
);
SmartDialog.dismiss(tag: DailySignInDialog.dialogTag);
} catch (error) {
SCLoadingManager.hide();
debugPrint('[SignInReward][Dialog] check-in failed error=$error');
if (!mounted) {
return;
}
@ -345,12 +347,17 @@ class _DailySignInDialogState extends State<DailySignInDialog> {
}
}
DailySignInDialogData _markCurrentItemAsClaimed(DailySignInDialogData data) {
DailySignInDialogData _markDayAsClaimed(
DailySignInDialogData data,
int dayIndex,
) {
return data.copyWith(
checkedToday: true,
items:
data.items.map((item) {
if (item.status == DailySignInRewardStatus.current) {
if ((dayIndex > 0 && item.day == dayIndex) ||
(dayIndex <= 0 &&
item.status == DailySignInRewardStatus.current)) {
return item.copyWith(status: DailySignInRewardStatus.claimed);
}
return item;
@ -358,6 +365,15 @@ class _DailySignInDialogState extends State<DailySignInDialog> {
);
}
String _debugItemsSummary(List<DailySignInDialogItem> items) {
return items
.map(
(item) =>
'{day:${item.day},title:${item.title},status:${item.status.name}}',
)
.join(',');
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;

View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -22,7 +22,7 @@
## 已完成模块
- 已按 2026-04-20 最新首页视觉需求,为 Party 房间列表前 3 个房卡接入新的本地排名边框 SVGA桌面“房间排序前三的框”中的 3 份素材已导入工程并挂到首页房卡最上层,仅作用于当前列表前 3 项,其余房卡保持原样;后续又继续为前三房卡底部信息区补了横向与底部安全区,避免外扩边框直接压住国旗、房名和在线人数。同时 `Me` 板块里的 `Recent / Followed` tab 已继续对齐上方首页 tab 的斜体字样式,并移除了点击时的波浪反馈。
- 已按 2026-04-20 最新房间联调继续修正房主在自己房间里的麦位操作回归:当前房主/管理员点击自己所在麦位时,不再被“直接打开资料卡”逻辑短路,会重新回到底部麦位菜单,从而正常看到自己可用的上下麦/禁麦操作;同时上麦、下麦、禁麦、解禁、锁麦、解锁这些动作在接口成功后会立即回写本地麦位状态,并主动补拉一次最新麦位列表,减少房主端自己操作后 UI 状态延迟或短暂错乱。
- 已按 2026-04-20 最新确认继续完成 `Wallet -> Recharge` 的 MiFaPay 真链路接入:页面仍保持“原页面结构 + 局部新增”的方案,只在原充值页白色内容区顶部新增 `Recharge methods`,且该标题已改成项目常见的土豪金色;`Google Pay / Apple Pay` 继续保留原生内购列表,`MiFaPay` 已从占位切成真实接口驱动,进入页面会拉 `/order/web/pay/country``/order/web/pay/commodity`,底部弹窗会展示真实商品与支付渠道,确认后调用 `/order/web/pay/recharge` 下单并拉起 MiFaPay H5 WebView。用户关闭收银台返回 App 后,当前会立即刷新钱包余额并轮询最新金币额;命中到账后会直接提示购买成功,避免只停留在“支付确认中”
- 已按 2026-04-20 最新调整撤掉 `Wallet -> Recharge` 中新增的 MiFaPay 第三方支付 UI 与页面逻辑:当前充值页仅保留原生 `Google Pay / Apple Pay` 入口与商品列表,`Recharge methods` 区域也已收敛为单一原生支付卡片;此前接入的 MiFaPay 方法选择、底部弹窗、H5 收银台页以及对应页面级状态管理已从现有充值链路移除,避免继续对当前版本产生影响
- 已按 2026-04-18 联调要求继续收口幸运礼物链路:项目默认 API host 已临时切到 `http://192.168.110.43:1100/` 方便直连测试环境;`/gift/give/lucky-gift` 请求体也已补齐为最新结构,除原有 `giftId/quantity/roomId/acceptUserIds/checkCombo` 外,会稳定携带 `gameId: null``accepts: []``dynamicContentId: null``songId: null` 这几组字段,便于和当前后端参数定义保持一致。
- 已继续补齐 `GAME_LUCKY_GIFT` 的 socket 播报与中奖动效:房间群消息收到该类型后,现在会统一落聊天室中奖消息、奖励弹层队列与发送者余额回写,不再只在 `3x+` 时才触发顶部奖励动画;同时全局飘窗已改为按服务端 `globalNews` 字段决定是否展示,避免再用前端本地倍率硬编码去猜。
- 已按最新 UI 口径重排幸运礼物中奖展示:顶部 `LuckGiftNomorAnimWidget` 不再叠加大头像、倍率和奖励框,只在“单个礼物倍率 `> 10x`”或“单次中奖金币 `> 5000`”时负责播全屏 `luck_gift_reward_burst.svga`;原本的 `luck_gift_reward_frame.svga` 已改挂到房间礼物播报条最右侧,并在框内显示 `+formattedAwardAmount`,金额文案直接复用此前大头像下方那一份中奖金币额。
@ -164,9 +164,6 @@
- `需求进度.md`
- `lib/main.dart`
- `lib/modules/wallet/recharge/recharge_page.dart`
- `lib/modules/wallet/recharge/recharge_method_bottom_sheet.dart`
- `lib/modules/wallet/recharge/mifa_pay_webview_page.dart`
- `lib/services/payment/mifa_pay_manager.dart`
- `lib/shared/business_logic/models/res/sc_mifa_pay_res.dart`
- `lib/shared/business_logic/repositories/config_repository.dart`
- `lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart`