FunyMeeAI/lib/features/purchase/purchase_screen.dart

997 lines
32 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 '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 '../web/app_web_view_screen.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,
) {
return showModalBottomSheet<PaymentMethodItem>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: PencilTheme.genNavBackStroke,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
'Payment method',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 17,
fontWeight: FontWeight.w700,
color: PencilTheme.stone900,
),
),
const SizedBox(height: 12),
for (var i = 0; i < methods.length; i++) ...[
if (i > 0)
Divider(
height: 1,
thickness: 1,
color: PencilTheme.genNavBackStroke,
),
ListTile(
contentPadding: EdgeInsets.zero,
minLeadingWidth: _PaymentMethodSheetIcon.slotWidth + 8,
leading: _PaymentMethodSheetIcon(
iconUrl: methods[i].icon,
),
title: Row(
children: [
Expanded(
child: Text(
methods[i].displayName.isNotEmpty
? methods[i].displayName
: (methods[i].paymentMethod ?? 'Payment'),
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
color: PencilTheme.stone900,
),
overflow: TextOverflow.ellipsis,
),
),
if (methods[i].recommend == true) ...[
const SizedBox(width: 6),
Text(
'Recommended',
style: GoogleFonts.inter(
fontSize: 11,
color: PencilTheme.underlineGold,
fontWeight: FontWeight.w600,
),
),
],
],
),
subtitle: methods[i].bonusLabel != null
? Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
methods[i].bonusLabel!,
style: GoogleFonts.inter(
fontSize: 12,
color: PencilTheme.underlineGold,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
: null,
onTap: () => Navigator.pop(ctx, methods[i]),
),
],
],
),
),
),
);
},
);
}
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);
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 Container(
decoration: const BoxDecoration(
gradient: PencilTheme.yellowWhitePageGradient,
),
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] + [PaymentProductItem.bonusCredits](换皮线网 `contrast` + `saturation`)。
static String? _bonusDisplayLine({
required int base,
required int gift,
required int total,
}) {
if (total <= 0) return null;
if (gift > 0 && base > 0) {
return '+$total bonus (incl. $gift gift)';
}
if (gift > 0) {
return '+$gift gift credits';
}
return '+$base 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 bonusBase = item.bonus ?? 0;
final bonusGift = item.bonusCredits ?? 0;
final bonusTotal = bonusBase + bonusGift;
final bonusLine = _bonusDisplayLine(
base: bonusBase,
gift: bonusGift,
total: bonusTotal,
);
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),
),
),
),
],
),
),
),
);
}
}
/// 支付方式图标:与 app_client [_PaymentIcon] 一致用 [BoxFit.contain] 完整显示、不变形;
/// 使用横向矩形槽位以贴近常见支付 Logo 比例(相对 40×40 裁切更自然)。
class _PaymentMethodSheetIcon extends StatelessWidget {
const _PaymentMethodSheetIcon({this.iconUrl});
final String? iconUrl;
/// 横向略宽,给长方形原图留足空间(高度与 app_client 40 一致)。
static const double slotWidth = 56;
static const double slotHeight = 40;
@override
Widget build(BuildContext context) {
final url = iconUrl?.trim();
final fallback = Icon(
Icons.payment_outlined,
size: 24,
color: PencilTheme.underlineGold,
);
return SizedBox(
width: slotWidth,
height: slotHeight,
child: url != null && url.isNotEmpty
? Image.network(
url,
fit: BoxFit.contain,
alignment: Alignment.center,
errorBuilder: (_, _, _) => Center(child: fallback),
)
: Center(child: fallback),
);
}
}