FunyMeeAI/lib/features/purchase/third_party_payment_sheet.dart

552 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] 读取。
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],
tierCreditsForChannelPercent:
widget.summaryTierCredits ??
widget.product.credits ??
0,
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.bonusCredits],相对档位原始积分 [tierCredits] 换算为 `+N% More Credits`(过小为 `+<1% More Credits`)。
String? _channelBonusCreditsPercentLine(
int tierCredits,
int? channelBonusCredits,
) {
if (channelBonusCredits == null || channelBonusCredits <= 0) return null;
if (tierCredits <= 0) return null;
final rounded = ((channelBonusCredits * 100.0) / tierCredits).round();
if (rounded > 0) return '+$rounded% More Credits';
return '+<1% More Credits';
}
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.tierCreditsForChannelPercent,
required this.selected,
required this.iconColor,
required this.onSelect,
});
final PaymentMethodItem method;
/// 渠道赠送占比分母:与摘要一致的档位原始积分(非赠送)。
final int tierCreditsForChannelPercent;
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 = _channelBonusCreditsPercentLine(
tierCreditsForChannelPercent,
method.bonusCredits,
);
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: [
Text(
title,
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w700,
color: PencilTheme.stone900,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (channelBonusLine != null) ...[
const SizedBox(height: 4),
Text(
channelBonusLine,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: PencilTheme.profileCredits,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (method.recommend == true)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: PencilTheme.cardThumbBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Recommend',
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w600,
color: PencilTheme.stone700,
),
),
),
],
),
),
),
);
}
}
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);
}
}