petsHero-AI/lib/features/recharge/recharge_screen.dart

734 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import '../../core/adjust/adjust_events.dart';
import '../../core/api/api_config.dart';
import '../../core/api/services/payment_api.dart';
import '../../core/auth/auth_service.dart';
import '../../core/user/account_refresh.dart';
import '../../core/log/app_logger.dart';
import '../../core/theme/app_colors.dart';
import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart';
import '../../shared/widgets/top_nav_bar.dart';
import 'google_play_purchase_service.dart';
import 'models/activity_item.dart';
import 'models/payment_method_item.dart';
import 'payment_webview_screen.dart';
/// Recharge screen - matches Pencil tPjdN
/// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities
class RechargeScreen extends StatefulWidget {
static final _log = AppLogger('Recharge');
const RechargeScreen({super.key});
@override
State<RechargeScreen> createState() => _RechargeScreenState();
}
class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObserver {
List<ActivityItem> _activities = [];
bool _loadingTiers = true;
String? _tierError;
/// 当前正在支付的商品 code仅该 item 的 Buy 显示 loading
String? _loadingProductId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
refreshAccount();
_fetchActivities();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) {
setState(() => _loadingProductId = null);
}
}
Future<void> _fetchActivities() async {
setState(() {
_loadingTiers = true;
_tierError = null;
});
try {
await AuthService.loginComplete;
final country = UserState.navigate.value ?? '';
final res = defaultTargetPlatform == TargetPlatform.iOS
? await PaymentApi.getApplePayActivities(
vambrace: country.isEmpty ? null : country,
)
: await PaymentApi.getGooglePayActivities(
vambrace: country.isEmpty ? null : country,
);
if (!mounted) return;
if (res.isSuccess && res.data != null) {
final data = res.data as Map<String, dynamic>?;
final summon = data?['summon'] as List<dynamic>? ?? [];
final list = summon
.whereType<Map<String, dynamic>>()
.map((e) => ActivityItem.fromJson(e))
.where((e) => e.code.isNotEmpty)
.toList();
setState(() {
_activities = list;
_loadingTiers = false;
});
} else {
setState(() {
_activities = [];
_loadingTiers = false;
_tierError = res.msg.isNotEmpty ? res.msg : 'Failed to load';
});
}
} catch (e) {
if (mounted) {
setState(() {
_activities = [];
_loadingTiers = false;
_tierError = e.toString();
});
}
}
}
Future<void> _onBuy(ActivityItem item) async {
if (_loadingProductId != null) return;
setState(() => _loadingProductId = item.code);
final price = AdjustEvents.parsePrice(item.actualAmount);
if (price != null) {
final tierToken = AdjustEvents.tierTokenFromPrice(price);
if (tierToken != null) {
AdjustEvents.trackTier(tierToken);
}
}
final titleLower = item.title.toLowerCase();
if (titleLower.contains('monthly vip')) {
AdjustEvents.trackMonthlyVip();
} else if (titleLower.contains('weekly vip')) {
AdjustEvents.trackWeeklyVip();
}
final useThirdParty =
UserState.enableThirdPartyPayment.value == true;
final uid = UserState.userId.value;
if (useThirdParty && uid != null && uid.isNotEmpty) {
await _runThirdPartyPayment(item, uid);
} else {
await _runGooglePay(item);
}
}
/// 第三方支付流程:获取支付方式 -> 弹窗选择 -> 创建订单 -> 打开支付页
Future<void> _runThirdPartyPayment(ActivityItem item, String userId) async {
final activityId = item.activityId;
if (activityId == null || activityId.isEmpty) {
if (mounted) {
_showSnackBar(context, 'Invalid product', isError: true);
}
AdjustEvents.trackPaymentFailed();
return;
}
if (!mounted) return;
try {
final country = UserState.navigate.value;
final methodsRes = await PaymentApi.getPaymentMethods(
warrior: activityId,
vambrace: country?.isEmpty == true ? null : country,
);
if (!mounted) return;
if (!methodsRes.isSuccess || methodsRes.data == null) {
_showSnackBar(
context,
methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods',
isError: true,
);
AdjustEvents.trackPaymentFailed();
return;
}
final methodsList = methodsRes.data is Map<String, dynamic>
? (methodsRes.data! as Map<String, dynamic>)['renew'] as List<dynamic>?
: null;
if (methodsList == null || methodsList.isEmpty) {
_showSnackBar(context, 'No payment methods available', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final methods = methodsList
.whereType<Map<String, dynamic>>()
.map((e) => PaymentMethodItem.fromJson(e))
.where((m) => m.paymentMethod.isNotEmpty)
.toList();
if (methods.isEmpty) {
_showSnackBar(context, 'No payment methods available', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
if (!mounted) return;
final selected = await showDialog<PaymentMethodItem>(
context: context,
builder: (ctx) => _PaymentMethodDialog(methods: methods),
);
if (!mounted) return;
if (selected == null) {
setState(() => _loadingProductId = null);
return;
}
await _createOrderAndOpenUrl(
userId: userId,
activityId: activityId,
productId: item.code,
paymentMethod: selected.paymentMethod,
subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod,
);
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) {
setState(() => _loadingProductId = null);
}
}
}
/// 创建订单;若为 Google Pay 则调起内购并上报凭据,否则打开支付链接
Future<void> _createOrderAndOpenUrl({
required String userId,
required String activityId,
required String productId,
required String paymentMethod,
String? subPaymentMethod,
}) async {
if (!mounted) return;
try {
final createRes = await PaymentApi.createPayment(
sentinel: ApiConfig.appId,
asset: userId,
warrior: activityId,
resource: paymentMethod,
ceremony: subPaymentMethod,
);
if (!mounted) return;
if (!createRes.isSuccess) {
_showSnackBar(
context,
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order',
isError: true,
);
AdjustEvents.trackPaymentFailed();
return;
}
final data = createRes.data is Map<String, dynamic>
? createRes.data as Map<String, dynamic>
: null;
final orderId = data?['federation']?.toString();
if (_isGooglePay(paymentMethod, subPaymentMethod)) {
if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final purchaseData = await GooglePlayPurchaseService.launchPurchaseAndReturnData(productId);
if (!mounted) return;
if (purchaseData != null && purchaseData.isNotEmpty && orderId != null && orderId.isNotEmpty) {
RechargeScreen._log.d('googlepay 入参: federation=$orderId, asset=$userId');
RechargeScreen._log.d('googlepay 入参: merchant(length=${purchaseData.length}) ${purchaseData.length > 400 ? "${purchaseData.substring(0, 400)}..." : purchaseData}');
final googlepayRes = await PaymentApi.googlepay(
merchant: purchaseData,
federation: orderId,
asset: userId,
);
if (!mounted) return;
if (googlepayRes.isSuccess) {
_showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess();
} else {
_showSnackBar(context, googlepayRes.msg.isNotEmpty ? googlepayRes.msg : 'Payment verification failed.', isError: true);
AdjustEvents.trackPaymentFailed();
}
} else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
AdjustEvents.trackPaymentFailed();
}
return;
}
final payUrl = data?['convert']?.toString();
if (payUrl != null && payUrl.isNotEmpty) {
if (mounted) {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl),
),
);
_showSnackBar(context, 'Order created. Complete payment in the page.');
AdjustEvents.trackPurchaseSuccess();
}
} else {
if (mounted) {
_showSnackBar(context, 'Order created. Awaiting payment confirmation.');
}
AdjustEvents.trackPurchaseSuccess();
}
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) {
setState(() => _loadingProductId = null);
}
}
}
/// ceremony==GooglePay 或 resource==GOOGLEPAY 时走谷歌内购并上报
static bool _isGooglePay(String paymentMethod, String? subPaymentMethod) {
final r = paymentMethod.trim().toLowerCase();
final c = (subPaymentMethod ?? '').trim().toLowerCase();
return r == 'googlepay' || c == 'googlepay';
}
/// 三方为 false 时走谷歌应用内支付
Future<void> _runGooglePay(ActivityItem item) async {
if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
await _launchGooglePlayPurchase(item);
}
/// 调起 Google Play 内购(商品 ID = item.code / helm
Future<void> _launchGooglePlayPurchase(ActivityItem item) async {
if (!mounted) return;
try {
final success = await GooglePlayPurchaseService.launchPurchase(item.code);
if (!mounted) return;
if (success) {
_showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess();
} else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
AdjustEvents.trackPaymentFailed();
}
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) {
setState(() => _loadingProductId = null);
}
}
}
void _showSnackBar(BuildContext context, String message, {bool isError = false}) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red.shade700 : null,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: TopNavBar(
title: 'Recharge',
showBackButton: true,
onBack: () => Navigator.of(context).pop(),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CreditsSection(
currentCredits:
UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPaddingLarge,
vertical: AppSpacing.xxl,
),
child: Text(
'Select Tier',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.textPrimary,
),
),
),
if (_loadingTiers)
const Padding(
padding: EdgeInsets.all(AppSpacing.xxl),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (_tierError != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding),
child: Column(
children: [
Text(
_tierError!,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.md),
TextButton(
onPressed: _fetchActivities,
child: const Text('Retry'),
),
],
),
)
else if (_activities.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding),
child: Text(
'No packages available',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
)
else
...List.generate(_activities.length, (i) {
final item = _activities[i];
final isRecommended = i == 1;
final isPopular = i == 2;
return Padding(
padding: EdgeInsets.only(
bottom: i < _activities.length - 1
? AppSpacing.xl
: 0,
),
child: _TierCardFromActivity(
item: item,
badge: isRecommended
? _TierBadge.recommended
: isPopular
? _TierBadge.popular
: _TierBadge.none,
loading: _loadingProductId == item.code,
onBuy: () => _onBuy(item),
),
);
}),
],
),
),
);
}
}
/// 支付方式选择弹窗(三方支付列表)
class _PaymentMethodDialog extends StatelessWidget {
const _PaymentMethodDialog({required this.methods});
final List<PaymentMethodItem> methods;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Select payment method'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: methods
.map(
(m) => ListTile(
title: Text(m.displayName),
subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty
? Text(m.subPaymentMethod!, style: AppTypography.caption)
: null,
trailing: m.recommend
? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary))
: null,
onTap: () => Navigator.of(context).pop(m),
),
)
.toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
);
}
}
enum _TierBadge { none, recommended, popular }
class _TierCardFromActivity extends StatelessWidget {
const _TierCardFromActivity({
required this.item,
required this.badge,
required this.loading,
required this.onBuy,
});
final ActivityItem item;
final _TierBadge badge;
final bool loading;
final VoidCallback onBuy;
@override
Widget build(BuildContext context) {
final content = Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xxl,
vertical: AppSpacing.xl,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: AppColors.shadowLight,
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.creditsDisplay,
style: AppTypography.bodyLarge.copyWith(
color: AppColors.textPrimary,
),
),
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
item.priceDisplayWithDollar,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
if (item.originAmount != null &&
item.originAmount!.isNotEmpty &&
item.originAmount != item.actualAmount) ...[
const SizedBox(width: AppSpacing.sm),
Text(
item.originAmount!,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
decoration: TextDecoration.lineThrough,
decorationColor: AppColors.textSecondary,
),
),
],
if (item.bonus > 0) ...[
const SizedBox(width: AppSpacing.sm),
Text(
'Bonus: ${item.bonus} credits',
style: AppTypography.caption.copyWith(
color: AppColors.primary,
),
),
],
],
),
],
),
),
_BuyButton(onTap: onBuy, loading: loading),
],
),
);
if (badge == _TierBadge.none) {
return content;
}
return Stack(
clipBehavior: Clip.none,
children: [
content,
Positioned(
top: 0,
right: AppSpacing.screenPadding,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: badge == _TierBadge.recommended
? AppColors.primaryLight
: AppColors.accentOrangeLight,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
),
),
child: Text(
badge == _TierBadge.recommended
? 'Recommended'
: 'Most Popular',
style: AppTypography.label.copyWith(
color: badge == _TierBadge.recommended
? AppColors.primary
: AppColors.accentOrange,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
}
class _CreditsSection extends StatelessWidget {
const _CreditsSection({required this.currentCredits});
final String currentCredits;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(AppSpacing.screenPadding),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xxl,
vertical: AppSpacing.xl,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.5),
),
boxShadow: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
const SizedBox(width: AppSpacing.md),
Text(
currentCredits,
style: AppTypography.bodyLarge.copyWith(
fontSize: 32,
fontWeight: FontWeight.w700,
color: AppColors.surface,
),
),
const Spacer(),
Text(
'Current Credits',
style: AppTypography.caption.copyWith(
color: AppColors.surface.withValues(alpha: 0.8),
),
),
],
),
);
}
}
class _BuyButton extends StatelessWidget {
const _BuyButton({required this.onTap, this.loading = false});
final VoidCallback onTap;
final bool loading;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: loading ? null : onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary,
borderRadius: BorderRadius.circular(12),
),
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.surface),
),
)
: Text(
'Buy',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface,
fontWeight: FontWeight.w600,
),
),
),
);
}
}