FunyMeeAI/lib/features/purchase/purchase_screen.dart

853 lines
26 KiB
Dart
Raw Permalink 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 'dart:async';
import 'dart:io';
import 'package:client_proxy_framework/client_proxy_framework.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/app_env.dart';
import '../../core/payment/google_play_order_recovery.dart';
import '../../core/user/user_state.dart';
import '../../design/pencil_theme.dart';
import '../../widgets/pencil_chrome.dart';
import '../../widgets/pencil_yellow_white_background.dart';
import '../web/app_web_view_screen.dart';
import 'third_party_payment_sheet.dart';
/// `ETbdo` Purchase Point黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。
/// 商品来自 [PaymentFlowCatalog.loadStoreActivities]。
/// - [ExtConfigData.allowThirdPartyPayment] 为 `true` 时:先 [PaymentApi.getPaymentMethods],再
/// [ThirdPartyCheckoutCoordinator.createOrder]**与 app_client [RechargeScreen._createOrderAndOpenUrl] 一致**
/// - [CreatePaymentResponse.payUrl] 为空(线网常为无 `convert`)→ [NativeIapCoordinator.purchaseGooglePlayAfterCreatePayment]
/// (仅拉起 Play + [PaymentApi.googlepay]**不**轮询订单。
/// - [payUrl] 非空 → WebView + [ThirdPartyPaymentWatch]。
/// - 否则 Android 直接 [NativeIapCoordinator.purchaseGooglePlay]iOS 无内购接线时提示。
class PurchaseScreen extends StatefulWidget {
const PurchaseScreen({super.key});
@override
State<PurchaseScreen> createState() => _PurchaseScreenState();
}
class _PurchaseScreenState extends State<PurchaseScreen>
with WidgetsBindingObserver {
List<PaymentProductItem> _products = [];
bool _loading = true;
String? _loadError;
bool _paying = false;
int? _selectedIndex;
ThirdPartyPaymentWatch? _thirdPartyWatch;
void _resetPayingState() {
if (!mounted) return;
setState(() {
_paying = false;
_selectedIndex = null;
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_thirdPartyWatch?.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Defer network + setState until after first frame so the route can paint and
// the main isolate stays responsive (avoids input ANR when opening this screen).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(runGooglePlayOrderRecovery());
_loadProducts(isInitial: true);
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 对齐 app_client从 Google Play 返回时先解锁支付按钮,避免取消/失败后长时间转圈等待超时。
if (state == AppLifecycleState.resumed && _paying && mounted) {
_resetPayingState();
}
}
Future<void> _loadProducts({bool isInitial = false}) async {
if (!isInitial) {
setState(() {
_loading = true;
_loadError = null;
});
}
final res = await PaymentFlowCatalog.loadStoreActivities();
if (!mounted) return;
if (!res.isSuccess || res.data == null) {
setState(() {
_loading = false;
_loadError = res.msg.isNotEmpty ? res.msg : 'Failed to load products';
_products = [];
});
return;
}
final list = res.data!.productList ?? [];
setState(() {
_loading = false;
_products = list;
});
}
bool _useThirdPartyFromExt(ExtConfigData? ext) =>
ext?.allowThirdPartyPayment == true;
Future<PaymentMethodItem?> _pickPaymentMethod(
List<PaymentMethodItem> methods,
PaymentProductItem product,
) {
return showThirdPartyPaymentMethodSheet(
context,
methods: methods,
product: product,
summaryTierCredits: product.credits,
summaryTierBonus: product.bonus,
);
}
Future<void> _onBuyThirdParty(PaymentProductItem item, int index) async {
final uid = UserState.userId.value;
if (uid == null || uid.isEmpty) return;
final aidStr = item.activityId!.trim();
final aidInt = int.tryParse(aidStr);
if (aidInt == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Invalid activity id.')));
return;
}
setState(() {
_paying = true;
_selectedIndex = index;
});
final methodsRes = await PaymentApi.getPaymentMethods(activityId: aidInt);
if (!mounted) return;
if (!methodsRes.isSuccess || methodsRes.data == null) {
setState(() {
_paying = false;
_selectedIndex = null;
});
AnalyticsEvents.trackPaymentFailed();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
methodsRes.msg.isNotEmpty
? methodsRes.msg
: 'Failed to load payment methods',
),
),
);
return;
}
final methods = methodsRes.data!.paymentMethods ?? [];
if (methods.isEmpty) {
setState(() {
_paying = false;
_selectedIndex = null;
});
AnalyticsEvents.trackPaymentFailed();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No payment methods available.')),
);
return;
}
setState(() {
_paying = false;
});
final picked = await _pickPaymentMethod(methods, item);
if (!mounted || picked == null) {
_resetPayingState();
return;
}
final pm = picked.paymentMethod?.trim() ?? '';
if (pm.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Invalid payment method.')));
return;
}
setState(() {
_paying = true;
_selectedIndex = index;
});
final innerSink = _PurchaseSink(
context: context,
onRefresh: () {
if (mounted) {
setState(() {
_paying = false;
_selectedIndex = null;
});
}
_thirdPartyWatch?.dispose();
_thirdPartyWatch = null;
},
onSuccess: () {
if (mounted) setState(() {});
},
);
final sink = PaymentSettlementSinkWithAnalytics(
inner: innerSink,
analyticsProduct: item,
);
final outcome = await ThirdPartyCheckoutCoordinator.createOrder(
userId: uid,
activityId: aidStr,
paymentMethod: pm,
paymentType: pm,
subPaymentMethod: picked.subPaymentMethod,
);
if (!mounted) return;
if (!outcome.isSuccess) {
setState(() {
_paying = false;
_selectedIndex = null;
});
AnalyticsEvents.trackPaymentFailed();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(outcome.message ?? 'Create order failed')),
);
return;
}
final orderId = outcome.orderId!;
final payUrl = outcome.payUrl;
if (NativeIapCoordinator.shouldLaunchGooglePlayBillingInsteadOfWeb(
payUrl,
)) {
if (!Platform.isAndroid) {
setState(() {
_paying = false;
_selectedIndex = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Google Play billing is only available on Android.'),
),
);
return;
}
final storePid = item.productId!.trim();
final created = outcome.createResponse;
if (created is! CreatePaymentResponse) {
setState(() {
_paying = false;
_selectedIndex = null;
});
AnalyticsEvents.trackPaymentFailed();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid payment response.')),
);
return;
}
await NativeIapCoordinator.purchaseGooglePlayAfterCreatePayment(
sink: sink,
userId: uid,
storeProductId: storePid,
createResponse: created,
createPaymentApp: currentBackendAppType(),
);
_resetPayingState();
return;
}
if (payUrl != null && payUrl.trim().isNotEmpty) {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) =>
AppWebViewScreen(title: 'Payment', initialUrl: payUrl.trim()),
),
);
}
if (!mounted) return;
_thirdPartyWatch?.dispose();
_thirdPartyWatch = ThirdPartyPaymentWatch(userId: uid, sink: sink);
_thirdPartyWatch!.start(orderId: orderId);
}
Future<void> _onBuy(PaymentProductItem item, int index) async {
final uid = UserState.userId.value;
if (uid == null || uid.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Please sign in first.')));
return;
}
final aid = item.activityId;
final pid = item.productId;
if (aid == null || aid.isEmpty || pid == null || pid.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Product data is incomplete.')),
);
return;
}
AnalyticsEvents.trackTierSelection(item);
final ext = ExtConfigRuntime.data.value;
if (_useThirdPartyFromExt(ext)) {
await _onBuyThirdParty(item, index);
return;
}
if (!Platform.isAndroid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'In-app purchases on iOS use the App Store flow. This build only wires Google Play on Android.',
),
),
);
return;
}
setState(() {
_paying = true;
_selectedIndex = index;
});
final innerSink = _PurchaseSink(
context: context,
onRefresh: () {
if (mounted) {
setState(() {
_paying = false;
_selectedIndex = null;
});
}
},
onSuccess: () {
if (mounted) setState(() {});
},
);
final sink = PaymentSettlementSinkWithAnalytics(
inner: innerSink,
analyticsProduct: item,
);
try {
await NativeIapCoordinator.purchaseGooglePlay(
sink: sink,
userId: uid,
activityId: aid,
storeProductId: pid,
createPaymentApp: currentBackendAppType(),
);
} finally {
_resetPayingState();
}
}
@override
Widget build(BuildContext context) {
return PencilYellowWhitePageBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 16, 8),
child: Row(
children: [
PencilRoundCloseButton(
onPressed: () => Navigator.of(context).pop(),
),
const Spacer(),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 6),
child: Text(
'Purchase Point',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'BonheurRoyale',
fontSize: 44,
height: 1.05,
color: const Color(0xFF5C3D2E),
),
),
),
ValueListenableBuilder<int>(
valueListenable: UserState.credits,
builder: (_, credits, _) {
return _CreditHeaderSection(
creditsText: credits.toStringAsFixed(2),
);
},
),
Expanded(
child: _loading
? const Center(
child: CircularProgressIndicator(
color: PencilTheme.underlineGold,
),
)
: _loadError != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_loadError!,
textAlign: TextAlign.center,
style: GoogleFonts.inter(
color: PencilTheme.stone600,
),
),
const SizedBox(height: 16),
TextButton(
onPressed: _loadProducts,
child: Text(
'Retry',
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
color: PencilTheme.underlineGold,
),
),
),
],
),
),
)
: _products.isEmpty
? Center(
child: Text(
'No products available',
style: GoogleFonts.inter(color: PencilTheme.stone600),
),
)
: _ProductGrid(
products: _products,
paying: _paying,
selectedIndex: _selectedIndex,
onTap: _onBuy,
),
),
],
),
),
),
);
}
}
class _PurchaseSink implements PaymentSettlementSink {
_PurchaseSink({
required this.context,
required this.onRefresh,
required this.onSuccess,
});
final BuildContext context;
final VoidCallback onRefresh;
final VoidCallback onSuccess;
@override
void onPaymentSettled(PaymentSettlement settlement) {
onRefresh();
if (!context.mounted) return;
switch (settlement.type) {
case PaymentFlowOutcomeType.success:
UserAccountRefresh.fetchAndNotify(
app: currentBackendAppType(),
userId: UserState.userId.value,
onAccount: (a) {
if (a.credits != null) UserState.setCredits(a.credits!);
},
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settlement.message ?? 'Payment successful',
style: GoogleFonts.inter(),
),
),
);
onSuccess();
break;
case PaymentFlowOutcomeType.failure:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settlement.message ?? 'Payment failed',
style: GoogleFonts.inter(),
),
),
);
break;
case PaymentFlowOutcomeType.cancelled:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settlement.message ?? 'Cancelled',
style: GoogleFonts.inter(),
),
),
);
break;
case PaymentFlowOutcomeType.timeout:
case PaymentFlowOutcomeType.nativePendingHostVerification:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settlement.message ?? 'Payment pending',
style: GoogleFonts.inter(),
),
),
);
break;
}
}
}
class _CreditHeaderSection extends StatelessWidget {
const _CreditHeaderSection({required this.creditsText});
final String creditsText;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
// Explicit height: as a non-flex child above [Expanded], this Column gets
// unbounded max height; [Stack] must have bounded constraints.
Container(
width: double.infinity,
height: 120,
decoration: const BoxDecoration(
color: Color(0xFFFCE952),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(22),
topRight: Radius.circular(22),
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 17,
top: 11,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFAE238), Color(0xFFF5BE5D)],
),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white, width: 1.5),
),
child: Text(
'Credit :',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 56,
child: Text(
creditsText,
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 36,
fontWeight: FontWeight.w800,
color: const Color(0xFF1F2937),
),
),
),
],
),
),
Image.asset(
'assets/images/xiabiao.png',
width: 60,
height: 17,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => const SizedBox(height: 17),
),
],
),
);
}
}
class _ProductGrid extends StatelessWidget {
const _ProductGrid({
required this.products,
required this.paying,
required this.selectedIndex,
required this.onTap,
});
final List<PaymentProductItem> products;
final bool paying;
final int? selectedIndex;
final void Function(PaymentProductItem item, int index) onTap;
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 28),
itemCount: (products.length + 1) ~/ 2,
itemBuilder: (context, row) {
final i0 = row * 2;
final i1 = i0 + 1;
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _ProductCard(
item: products[i0],
index: i0,
paying: paying,
selected: selectedIndex == i0,
onTap: onTap,
),
),
const SizedBox(width: 15),
Expanded(
child: i1 < products.length
? _ProductCard(
item: products[i1],
index: i1,
paying: paying,
selected: selectedIndex == i1,
onTap: onTap,
)
: const SizedBox.shrink(),
),
],
),
);
},
);
}
}
class _ProductCard extends StatelessWidget {
const _ProductCard({
required this.item,
required this.index,
required this.paying,
required this.selected,
required this.onTap,
});
final PaymentProductItem item;
final int index;
final bool paying;
final bool selected;
final void Function(PaymentProductItem item, int index) onTap;
static final _money = RegExp(r'[\d.]+');
/// 赠送展示:仅 [PaymentProductItem.bonus](线网常为 `contrast` 映射),`+N bonus`。
static String? _bonusDisplayLine(int total) {
if (total <= 0) return null;
return '+$total bonus';
}
static int? _discountPercent(String? actual, String? origin) {
final a = double.tryParse(_money.firstMatch(actual ?? '')?.group(0) ?? '');
final o = double.tryParse(_money.firstMatch(origin ?? '')?.group(0) ?? '');
if (a == null || o == null || o <= 0 || a >= o) return null;
return ((1 - a / o) * 100).round();
}
/// 展示用:金额前加 `$`(已有 `$` / `¥` 则不改)。
static String _withDollarPrefix(String amount) {
final t = amount.trim();
if (t.isEmpty || t == '') return t;
if (t.startsWith(r'$') || t.startsWith('¥') || t.startsWith('')) {
return t;
}
return r'$' + t;
}
@override
Widget build(BuildContext context) {
final rawTitle = item.title;
final creditsTopLabel = item.credits != null
? 'Credits:${item.credits}'
: (rawTitle != null && rawTitle.trim().isNotEmpty)
? 'Credits:${rawTitle.trim()}'
: 'Credits:—';
final actual = item.actualAmount ?? '';
final origin = item.originAmount;
final bonusLine = _bonusDisplayLine(item.bonus ?? 0);
final pct = _discountPercent(item.actualAmount, item.originAmount);
return Material(
color: const Color(0xE6FEE56A),
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: paying ? null : () => onTap(item, index),
borderRadius: BorderRadius.circular(10),
child: SizedBox(
height: 130,
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
creditsTopLabel,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w800,
color: PencilTheme.stone600,
),
),
const SizedBox(height: 8),
Text(
_withDollarPrefix(actual),
style: GoogleFonts.inter(
fontSize: 26,
fontWeight: FontWeight.w800,
color: const Color(0xFF0A0A0A),
),
),
if (origin != null && origin.isNotEmpty) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0x80FFE3E3),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_withDollarPrefix(origin),
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: const Color(0xFFDB6525),
decoration: TextDecoration.lineThrough,
decorationColor: const Color(0xFFDB6525),
),
),
),
],
if (bonusLine != null) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: Text(
bonusLine,
textAlign: TextAlign.right,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: PencilTheme.stone600,
),
),
),
],
],
),
),
if (pct != null && pct > 0)
Positioned(
right: -4,
top: -18,
child: SizedBox(
width: 60,
height: 33,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
'assets/images/credit_tag.png',
fit: BoxFit.fill,
width: 80,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.only(top: 4, right: 10),
child: Text(
'$pct% Off',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
color: Colors.white,
),
),
),
],
),
),
),
if (paying && selected)
Positioned.fill(
child: Container(
alignment: Alignment.center,
color: Colors.white24,
child: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
],
),
),
),
);
}
}