FunyMeeAI/lib/features/purchase/purchase_screen.dart
2026-04-10 15:36:08 +08:00

596 lines
19 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: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/user/user_state.dart';
import '../../design/pencil_theme.dart';
import '../../widgets/pencil_chrome.dart';
/// `ETbdo` Purchase Point黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。
/// 商品来自 [PaymentFlowCatalog.loadStoreActivities]Android 走 [NativeIapCoordinator.purchaseGooglePlay]。
class PurchaseScreen extends StatefulWidget {
const PurchaseScreen({super.key});
@override
State<PurchaseScreen> createState() => _PurchaseScreenState();
}
class _PurchaseScreenState extends State<PurchaseScreen> {
List<PaymentProductItem> _products = [];
bool _loading = true;
String? _loadError;
bool _paying = false;
int? _selectedIndex;
@override
void initState() {
super.initState();
// 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) _loadProducts(isInitial: true);
});
}
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;
});
}
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;
}
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 sink = _PurchaseSink(
context: context,
onRefresh: () {
if (mounted) setState(() => _paying = false);
},
onSuccess: () {
if (mounted) setState(() {});
},
);
await NativeIapCoordinator.purchaseGooglePlay(
sink: sink,
userId: uid,
activityId: aid,
storeProductId: pid,
createPaymentApp: currentBackendAppType(),
);
if (mounted) {
setState(() {
_paying = false;
});
}
}
@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.]+');
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();
}
@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 bonus = item.bonus;
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.w600,
color: PencilTheme.stone600,
),
),
const SizedBox(height: 8),
Text(
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(
origin,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: const Color(0xFFDB6525),
decoration: TextDecoration.lineThrough,
decorationColor: const Color(0xFFDB6525),
),
),
),
],
if (bonus != null && bonus > 0) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: Text(
'+$bonus Bonus',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: PencilTheme.stone600,
),
),
),
],
],
),
),
if (pct != null && pct > 0)
Positioned(
right: -4,
top: -8,
child: SizedBox(
width: 60,
height: 33,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
'assets/images/credit_tag.png',
fit: BoxFit.fill,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.only(top: 4),
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),
),
),
),
],
),
),
),
);
}
}