554 lines
18 KiB
Dart
554 lines
18 KiB
Dart
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);
|
||
}
|
||
}
|