petsHero-AI/lib/features/recharge/recharge_screen.dart
2026-03-15 11:25:09 +08:00

1068 lines
34 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();
// 进入充值页时执行补单,处理未核销订单
GooglePlayPurchaseService.runOrderRecovery();
}
@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 showModalBottomSheet<PaymentMethodItem>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => _PaymentMethodSheet(methods: methods),
);
if (!mounted) return;
if (selected == null) {
setState(() => _loadingProductId = null);
return;
}
await _createOrderAndOpenUrl(
userId: userId,
item: item,
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 则走统一入口 [_launchGooglePlayPurchase],否则打开支付链接
Future<void> _createOrderAndOpenUrl({
required String userId,
required ActivityItem item,
required String paymentMethod,
String? subPaymentMethod,
}) async {
if (!mounted) return;
try {
final activityId = item.activityId ?? '';
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)) {
await _launchGooglePlayPurchase(item,
serverOrderId: orderId, userId: userId);
return;
}
final payUrl = data?['convert']?.toString();
if (payUrl != null && payUrl.isNotEmpty) {
if (mounted) {
setState(() => _loadingProductId = null);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl),
),
);
if (mounted && orderId != null && orderId.isNotEmpty) {
_startOrderPolling(orderId: orderId, userId: userId);
}
_showSnackBar(
context, 'Order created. Complete payment in the page.');
AdjustEvents.trackPurchaseSuccess();
}
} else {
if (mounted) {
setState(() => _loadingProductId = null);
_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);
}
}
}
/// 三方支付 webview 关闭后轮询订单详情,间隔 1/3/7/15/31/63 秒status 为 SUCCESS|FAILED|CANCELED 或订单不存在或轮询结束则停止SUCCESS 时刷新用户信息
void _startOrderPolling({required String orderId, required String userId}) {
const delays = [1, 2, 4, 8, 16, 32]; // 累计 1,3,7,15,31,63 秒
Future<void> poll(int index) async {
if (index >= delays.length) return;
await Future<void>.delayed(Duration(seconds: delays[index]));
if (!mounted) return;
try {
final res = await PaymentApi.getOrderDetail(
asset: userId,
federation: orderId,
);
if (!mounted) return;
if (!res.isSuccess || res.data == null) {
RechargeScreen._log.d('订单轮询 orderId=$orderId 订单不存在或请求失败');
return;
}
final data = res.data as Map<String, dynamic>?;
final status = data?['line']?.toString().toUpperCase();
RechargeScreen._log.d('订单轮询 orderId=$orderId status=$status');
if (status == 'SUCCESS' || status == 'FAILED' || status == 'CANCELED') {
if (status == 'SUCCESS') {
refreshAccount();
}
return;
}
poll(index + 1);
} catch (e) {
RechargeScreen._log.w('订单轮询异常 orderId=$orderId: $e');
poll(index + 1);
}
}
poll(0);
}
/// 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 时走谷歌应用内支付:先创建订单再调起内购(与第三方选 Google Pay 一致)
Future<void> _runGooglePay(ActivityItem item) async {
if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.',
isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final userId = UserState.userId.value;
if (userId == null || userId.isEmpty) {
_showSnackBar(context, 'Please sign in to continue.', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final activityId = item.activityId;
if (activityId == null || activityId.isEmpty) {
if (mounted) _showSnackBar(context, 'Invalid product', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
try {
final createRes = await PaymentApi.createPayment(
sentinel: ApiConfig.appId,
asset: userId,
warrior: activityId,
resource: 'GooglePay',
ceremony: 'GooglePay',
);
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();
await _launchGooglePlayPurchase(item,
serverOrderId: orderId, userId: userId);
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) setState(() => _loadingProductId = null);
}
}
/// 谷歌内购统一入口:调起内购 → 回调 googlepay → 成功后再核销并刷新账户。
/// [serverOrderId] 有值时(来自 createPayment用作 federation否则用 Google orderId。
Future<void> _launchGooglePlayPurchase(ActivityItem item,
{String? serverOrderId, String? userId}) async {
if (!mounted) return;
try {
if (mounted) setState(() => _loadingProductId = null);
final result =
await GooglePlayPurchaseService.launchPurchaseAndReturnData(
item.code);
if (!mounted) return;
if (result == null) {
_showSnackBar(context, 'Purchase was cancelled or failed.',
isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final uid = userId ?? UserState.userId.value;
if (uid == null || uid.isEmpty) {
_showSnackBar(context, 'Please sign in to confirm your purchase.',
isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final federation = (serverOrderId != null && serverOrderId.isNotEmpty)
? serverOrderId
: result.orderId;
if (serverOrderId != null && serverOrderId.isNotEmpty) {
await GooglePlayPurchaseService.saveFederationForGoogleOrderId(
result.orderId, serverOrderId);
}
RechargeScreen._log.d('googlepay 入参: federation=$federation');
final googlepayRes = await PaymentApi.googlepay(
sample: result.payload.signature,
merchant: result.payload.purchaseData,
federation: federation,
asset: uid,
);
if (!mounted) return;
if (googlepayRes.isSuccess) {
final resData = googlepayRes.data is Map<String, dynamic>
? googlepayRes.data as Map<String, dynamic>
: null;
final line = (resData?['line']?.toString() ?? '').toUpperCase();
if (line == 'SUCCESS') {
await GooglePlayPurchaseService.completeAndConsumePurchase(
result.purchaseDetails);
if (serverOrderId != null && serverOrderId.isNotEmpty) {
await GooglePlayPurchaseService.removeFederationForGoogleOrderId(
result.orderId);
}
await refreshAccount();
}
if (mounted) {
_showSnackBar(context, 'Purchase completed.');
}
AdjustEvents.trackPurchaseSuccess();
} else {
_showSnackBar(
context,
googlepayRes.msg.isNotEmpty
? googlepayRes.msg
: 'Payment verification 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: _loadingProductId != null
? () {}
: () => Navigator.of(context).pop(),
),
),
body: Stack(
children: [
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];
const isRecommended = false;
const isPopular = false;
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),
),
);
}),
],
),
),
if (_loadingProductId != null)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: AppColors.overlayDark,
child: const Center(
child: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(AppColors.surface),
),
),
),
),
),
),
],
),
);
}
}
/// 支付方式选择弹窗(三方支付列表)- 1:1 匹配 Pencil PaymentMethodModal
class _PaymentMethodSheet extends StatefulWidget {
const _PaymentMethodSheet({required this.methods});
final List<PaymentMethodItem> methods;
@override
State<_PaymentMethodSheet> createState() => _PaymentMethodSheetState();
}
class _PaymentMethodSheetState extends State<_PaymentMethodSheet> {
PaymentMethodItem? _selected;
@override
void initState() {
super.initState();
_selected = widget.methods.first;
}
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
padding: EdgeInsets.fromLTRB(24, 20, 24, 32 + bottomPadding),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Select Payment Method',
style: AppTypography.bodyLarge.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
const SizedBox(height: 20),
...List.generate(widget.methods.length, (i) {
final m = widget.methods[i];
final isSelected = _selected == m;
return Padding(
padding: EdgeInsets.only(
bottom: i < widget.methods.length - 1 ? 12 : 0,
),
child: _PaymentMethodItem(
item: m,
isSelected: isSelected,
onTap: () => setState(() => _selected = m),
),
);
}),
const SizedBox(height: 20),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _selected != null
? () => Navigator.of(context).pop(_selected)
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Pay',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
],
),
),
);
}
}
/// 单条支付方式项 - 图标、名称、Recommended(deny取反)、单选
class _PaymentMethodItem extends StatelessWidget {
const _PaymentMethodItem({
required this.item,
required this.isSelected,
required this.onTap,
});
final PaymentMethodItem item;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
height: 64,
decoration: BoxDecoration(
color: isSelected ? const Color(0x158B5CF6) : AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.primary : AppColors.border,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
_PaymentIcon(iconUrl: item.icon),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
item.displayName,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
if (item.recommend) ...[
const SizedBox(width: 6),
Text(
'Recommended',
style: AppTypography.caption.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w600,
fontSize: 11,
),
),
],
],
),
),
],
),
),
_RadioIndicator(selected: isSelected),
],
),
),
);
}
}
/// 支付方式图标 - 优先接口 greylist否则 fallback正方形 40x40在 64 高度内最大),完整显示不裁切不变形
class _PaymentIcon extends StatelessWidget {
const _PaymentIcon({this.iconUrl});
final String? iconUrl;
static const double _size = 40;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: _size,
child: iconUrl != null && iconUrl!.isNotEmpty
? Image.network(
iconUrl!,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
LucideIcons.credit_card,
size: 24,
color: AppColors.primary,
),
)
: const Icon(
LucideIcons.credit_card,
size: 24,
color: AppColors.primary,
),
);
}
}
/// 单选指示器 - 选中实心紫,未选中空心灰
class _RadioIndicator extends StatelessWidget {
const _RadioIndicator({required this.selected});
final bool selected;
@override
Widget build(BuildContext context) {
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? AppColors.primary : Colors.transparent,
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
width: selected ? 0 : 1,
),
),
);
}
}
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: const [
BoxShadow(
color: AppColors.shadowLight,
blurRadius: 6,
offset: 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: [
const 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,
),
),
),
);
}
}