FunyMeeAI/lib/features/purchase/third_party_payment_sheet.dart

554 lines
18 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:client_proxy_framework/client_proxy_framework.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../design/pencil_theme.dart';
/// 本页「图标」相对设计稿的缩放(支付方式 logo、摘要钻石等
const double _paymentSheetIconScale = 1.5;
/// 与 `funymee_home.pen` **zL8hY / p7kQm** 对齐的第三方支付底部表单。
/// 先点选支付方式,再点底部 **Pay** 确认(与画板 `btnPayPrimary` 一致)。
///
/// [summaryTierCredits] / [summaryTierBonus] 与购买页摘要一致(档位积分 + 档位赠送);
/// 均为 `null` 时从 [product] 读取。各支付方式副标题的赠送比例仅读接口 [PaymentMethodItem.bonusRatio],不作本地推算。
Future<PaymentMethodItem?> showThirdPartyPaymentMethodSheet(
BuildContext context, {
required List<PaymentMethodItem> methods,
required PaymentProductItem product,
int? summaryTierCredits,
int? summaryTierBonus,
}) {
if (methods.isEmpty) return Future.value(null);
return showModalBottomSheet<PaymentMethodItem>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
// 使用框架 ModalBarrier 遮罩,避免与下层页面视觉上糊在一起。
barrierColor: PencilTheme.paymentModalDimScrim,
isDismissible: true,
builder: (ctx) {
final w = MediaQuery.sizeOf(ctx).width;
final sheetW = w < PencilTheme.designWidth ? w : PencilTheme.designWidth;
return Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
width: sheetW,
child: _ThirdPartyPaymentSheetBody(
methods: methods,
product: product,
summaryTierCredits: summaryTierCredits,
summaryTierBonus: summaryTierBonus,
),
),
);
},
);
}
class _ThirdPartyPaymentSheetBody extends StatefulWidget {
const _ThirdPartyPaymentSheetBody({
required this.methods,
required this.product,
this.summaryTierCredits,
this.summaryTierBonus,
});
final List<PaymentMethodItem> methods;
final PaymentProductItem product;
/// 购买页传入的档位积分;`null` 则用 [product.credits]。
final int? summaryTierCredits;
/// 购买页传入的档位赠送;`null` 则用 [product.bonus]。
final int? summaryTierBonus;
@override
State<_ThirdPartyPaymentSheetBody> createState() =>
_ThirdPartyPaymentSheetBodyState();
}
class _ThirdPartyPaymentSheetBodyState
extends State<_ThirdPartyPaymentSheetBody> {
late int _selectedIndex;
@override
void initState() {
super.initState();
_selectedIndex = 0;
}
String get _payLabel => 'Pay Now';
@override
Widget build(BuildContext context) {
final bottomSafe = MediaQuery.paddingOf(context).bottom;
final bottomPad = bottomSafe < 34 ? 34.0 : bottomSafe.toDouble();
const sheetRadius = BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
);
return Container(
decoration: BoxDecoration(
borderRadius: sheetRadius,
boxShadow: PencilTheme.paymentSheetOuterShadow,
),
child: ClipRRect(
borderRadius: sheetRadius,
clipBehavior: Clip.antiAlias,
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: sheetRadius,
border: const Border(
top: BorderSide(color: PencilTheme.genHintBorder, width: 1),
left: BorderSide(color: PencilTheme.genHintBorder, width: 1),
right: BorderSide(color: PencilTheme.genHintBorder, width: 1),
),
),
child: Stack(
fit: StackFit.loose,
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: PencilTheme.paymentSheetBodyGradient,
),
),
),
Positioned.fill(
child: Opacity(
opacity: 0.2,
child: Image.asset(
'assets/images/checker_20px_500.png',
fit: BoxFit.cover,
alignment: Alignment.topCenter,
filterQuality: FilterQuality.medium,
),
),
),
Material(
color: Colors.transparent,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(20, 10, 20, bottomPad),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: PencilTheme.paymentRadioOffStroke,
borderRadius: BorderRadius.circular(99),
),
),
),
const SizedBox(height: 18),
Text(
'Payment Methods',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w700,
color: PencilTheme.stone900,
height: 1.2,
),
),
const SizedBox(height: 18),
_PaymentSummaryCard(
product: widget.product,
tierCredits:
widget.summaryTierCredits ?? widget.product.credits,
tierBonus:
widget.summaryTierBonus ?? widget.product.bonus,
),
const SizedBox(height: 18),
Text(
'Payment',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: PencilTheme.stone600,
height: 1.2,
),
),
const SizedBox(height: 12),
for (var i = 0; i < widget.methods.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_PaymentMethodOptionCard(
method: widget.methods[i],
selected: _selectedIndex == i,
iconColor: _iconAccent(widget.methods[i]),
onSelect: () => setState(() => _selectedIndex = i),
),
],
const SizedBox(height: 18),
_PayCtaButton(
label: _payLabel,
onPressed: () => Navigator.pop(
context,
widget.methods[_selectedIndex],
),
),
],
),
),
),
],
),
),
),
);
}
static Color _iconAccent(PaymentMethodItem m) {
final n = m.displayName.toLowerCase();
if (n.contains('app store')) {
return PencilTheme.paymentAppStoreIconBlue;
}
return PencilTheme.stone900;
}
}
String _formatProductPrice(PaymentProductItem p) {
final a = p.actualAmount?.trim();
if (a == null || a.isEmpty) return '\$';
if (a.startsWith(r'$') || a.startsWith('¥')) return a;
return '\$$a';
}
/// 第三方支付方式副标题:赠送**比例**仅用服务端 [PaymentMethodItem.bonusRatio](与框架解析一致:`≤1` 为小数比例,否则为百分数),
/// 不用 `bonusCredits / 档位积分` 推算;无 `bonusRatio` 时再展示 [PaymentMethodItem.bonusCredits] 的绝对赠送文案。
String? _thirdPartyChannelBonusSubtitle(PaymentMethodItem method) {
final br = method.bonusRatio;
if (br != null && br > 0) {
final pct = br <= 1 ? (br * 100).round() : br.round();
return '+$pct% More Credits';
}
final bc = method.bonusCredits;
if (bc != null && bc > 0) {
return '+$bc More Credits';
}
return null;
}
class _PaymentSummaryCard extends StatelessWidget {
const _PaymentSummaryCard({
required this.product,
required this.tierCredits,
required this.tierBonus,
});
final PaymentProductItem product;
final int? tierCredits;
final int? tierBonus;
@override
Widget build(BuildContext context) {
final credits = tierCredits ?? 0;
final tierGift = tierBonus;
final price = _formatProductPrice(product);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: PencilTheme.genNavBackStroke),
boxShadow: PencilTheme.paymentSummaryCardShadow,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.diamond_outlined,
size: 22 * _paymentSheetIconScale,
color: PencilTheme.paymentSummaryGem,
),
const SizedBox(width: 10),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$credits',
style: GoogleFonts.inter(
fontSize: 17,
fontWeight: FontWeight.w700,
color: PencilTheme.stone900,
),
),
if (tierGift != null && tierGift > 0) ...[
const SizedBox(width: 4),
Text(
'+$tierGift',
style: GoogleFonts.inter(
fontSize: 17,
fontWeight: FontWeight.w700,
color: PencilTheme.paymentSummaryBonusGreen,
),
),
],
const SizedBox(width: 4),
Text(
'Credits',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: PencilTheme.paymentSummaryCreditsLabel,
),
),
],
),
),
Text(
price,
style: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w800,
color: PencilTheme.stone900,
),
),
],
),
);
}
}
class _PayCtaButton extends StatelessWidget {
const _PayCtaButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Container(
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
gradient: PencilTheme.paymentPayButtonGradient,
boxShadow: PencilTheme.paymentPayButtonShadow,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(999),
child: Center(
child: Text(
label,
style: GoogleFonts.inter(
fontSize: 17,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
),
);
}
}
class _PaymentMethodOptionCard extends StatelessWidget {
const _PaymentMethodOptionCard({
required this.method,
required this.selected,
required this.iconColor,
required this.onSelect,
});
final PaymentMethodItem method;
final bool selected;
final Color iconColor;
final VoidCallback onSelect;
static const double _radioSize = 22;
static const double _iconBox = 28 * _paymentSheetIconScale;
@override
Widget build(BuildContext context) {
final title = method.displayName.isNotEmpty
? method.displayName
: (method.paymentMethod ?? 'Payment');
final channelBonusLine = _thirdPartyChannelBonusSubtitle(method);
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? PencilTheme.paymentMethodSelectedStroke
: PencilTheme.genNavBackStroke,
width: selected ? 2 : 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: _radioSize,
height: _radioSize,
child: selected
? Stack(
alignment: Alignment.center,
children: [
Container(
width: _radioSize,
height: _radioSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: PencilTheme.paymentMethodSelectedStroke,
border: Border.all(
color: PencilTheme.paymentMethodSelectedStroke,
width: 2,
),
),
),
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
),
],
)
: Container(
width: _radioSize,
height: _radioSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: PencilTheme.paymentRadioOffStroke,
width: 2,
),
),
),
),
const SizedBox(width: 12),
SizedBox(
width: _iconBox,
height: _iconBox,
child: _PaymentMethodSheetIconSmall(
iconUrl: method.icon,
color: iconColor,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
title,
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w700,
color: PencilTheme.stone900,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (method.recommend == true) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: PencilTheme.cardThumbBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Recommend',
style: GoogleFonts.inter(
fontSize: 9,
fontWeight: FontWeight.w600,
height: 1.1,
color: PencilTheme.stone700,
),
),
),
],
],
),
if (channelBonusLine != null) ...[
const SizedBox(height: 4),
Text(
channelBonusLine,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: PencilTheme.profileCredits,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
),
),
);
}
}
class _PaymentMethodSheetIconSmall extends StatelessWidget {
const _PaymentMethodSheetIconSmall({required this.color, this.iconUrl});
final String? iconUrl;
final Color color;
@override
Widget build(BuildContext context) {
final url = iconUrl?.trim();
final fallback = Icon(
Icons.payment_outlined,
size: 22 * _paymentSheetIconScale,
color: color,
);
if (url != null && url.isNotEmpty) {
final img = Image.network(
url,
fit: BoxFit.contain,
alignment: Alignment.center,
errorBuilder: (_, _, _) => Center(child: fallback),
);
if (color == PencilTheme.paymentAppStoreIconBlue) {
return ColorFiltered(
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
child: img,
);
}
return img;
}
return Center(child: fallback);
}
}