feat(client/purchase): 全员优惠月付特殊卡片(¥6/$1,随渠道显价)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -587,6 +587,8 @@ abstract class AppText {
|
|||||||
String get orderRebuy;
|
String get orderRebuy;
|
||||||
String get payMethodNezha; // 请复制内容后在支付宝内打开 / Copy and open in Alipay
|
String get payMethodNezha; // 请复制内容后在支付宝内打开 / Copy and open in Alipay
|
||||||
String get orderStatusExpired; // 已过期 / Expired
|
String get orderStatusExpired; // 已过期 / Expired
|
||||||
|
String get promoBadge; // 超值月付 / Best value
|
||||||
|
String get promoOriginalPrice; // 原价 / Was
|
||||||
|
|
||||||
// ── 邀请好友(Spec ②占位) ──
|
// ── 邀请好友(Spec ②占位) ──
|
||||||
String get inviteEntry; // 邀请好友得会员 / Invite friends, earn membership
|
String get inviteEntry; // 邀请好友得会员 / Invite friends, earn membership
|
||||||
|
|||||||
@@ -511,4 +511,8 @@ class StringsEn extends AppText {
|
|||||||
String get settingsGroupAbout => 'About';
|
String get settingsGroupAbout => 'About';
|
||||||
@override
|
@override
|
||||||
String get orderStatusExpired => 'Expired';
|
String get orderStatusExpired => 'Expired';
|
||||||
|
@override
|
||||||
|
String get promoBadge => 'Limited-time offer';
|
||||||
|
@override
|
||||||
|
String get promoOriginalPrice => 'Was';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,4 +512,8 @@ class StringsEs extends AppText {
|
|||||||
String get settingsGroupAbout => 'Acerca de';
|
String get settingsGroupAbout => 'Acerca de';
|
||||||
@override
|
@override
|
||||||
String get orderStatusExpired => 'Caducado';
|
String get orderStatusExpired => 'Caducado';
|
||||||
|
@override
|
||||||
|
String get promoBadge => 'Oferta especial';
|
||||||
|
@override
|
||||||
|
String get promoOriginalPrice => 'Antes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,4 +512,8 @@ class StringsJa extends AppText {
|
|||||||
String get settingsGroupAbout => 'このアプリについて';
|
String get settingsGroupAbout => 'このアプリについて';
|
||||||
@override
|
@override
|
||||||
String get orderStatusExpired => '期限切れ';
|
String get orderStatusExpired => '期限切れ';
|
||||||
|
@override
|
||||||
|
String get promoBadge => '期間限定オファー';
|
||||||
|
@override
|
||||||
|
String get promoOriginalPrice => '通常価格';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,4 +512,8 @@ class StringsKo extends AppText {
|
|||||||
String get settingsGroupAbout => '정보';
|
String get settingsGroupAbout => '정보';
|
||||||
@override
|
@override
|
||||||
String get orderStatusExpired => '만료됨';
|
String get orderStatusExpired => '만료됨';
|
||||||
|
@override
|
||||||
|
String get promoBadge => '한정 특가';
|
||||||
|
@override
|
||||||
|
String get promoOriginalPrice => '정가';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,4 +512,8 @@ class StringsRu extends AppText {
|
|||||||
String get settingsGroupAbout => 'О приложении';
|
String get settingsGroupAbout => 'О приложении';
|
||||||
@override
|
@override
|
||||||
String get orderStatusExpired => 'Истёк';
|
String get orderStatusExpired => 'Истёк';
|
||||||
|
@override
|
||||||
|
String get promoBadge => 'Спецпредложение';
|
||||||
|
@override
|
||||||
|
String get promoOriginalPrice => 'Было';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,4 +505,8 @@ class StringsZh extends AppText {
|
|||||||
String get settingsGroupAbout => '关于';
|
String get settingsGroupAbout => '关于';
|
||||||
@override
|
@override
|
||||||
String get orderStatusExpired => '已过期';
|
String get orderStatusExpired => '已过期';
|
||||||
|
@override
|
||||||
|
String get promoBadge => '限时优惠';
|
||||||
|
@override
|
||||||
|
String get promoOriginalPrice => '原价';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class PayCatalogItem {
|
|||||||
required this.priceMinor,
|
required this.priceMinor,
|
||||||
required this.currency,
|
required this.currency,
|
||||||
this.priceUsdtMicro = 0,
|
this.priceUsdtMicro = 0,
|
||||||
|
this.promo = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String sku;
|
final String sku;
|
||||||
@@ -26,6 +27,7 @@ class PayCatalogItem {
|
|||||||
final int priceMinor; // CNY 分(展示)
|
final int priceMinor; // CNY 分(展示)
|
||||||
final String currency; // CNY
|
final String currency; // CNY
|
||||||
final int priceUsdtMicro; // USDT 微元(展示;各币种一口价)
|
final int priceUsdtMicro; // USDT 微元(展示;各币种一口价)
|
||||||
|
final bool promo; // 全员可见的限时优惠档(如 pro_month_promo)
|
||||||
|
|
||||||
factory PayCatalogItem.fromJson(Map<String, dynamic> j) => PayCatalogItem(
|
factory PayCatalogItem.fromJson(Map<String, dynamic> j) => PayCatalogItem(
|
||||||
sku: j['sku'] as String,
|
sku: j['sku'] as String,
|
||||||
@@ -34,6 +36,7 @@ class PayCatalogItem {
|
|||||||
priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0,
|
priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0,
|
||||||
currency: j['currency'] as String? ?? 'CNY',
|
currency: j['currency'] as String? ?? 'CNY',
|
||||||
priceUsdtMicro: (j['price_usdt_micro'] as num?)?.toInt() ?? 0,
|
priceUsdtMicro: (j['price_usdt_micro'] as num?)?.toInt() ?? 0,
|
||||||
|
promo: j['promo'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 约当月数(31→1 / 92→3 / 366→12),用于每月折算展示。
|
/// 约当月数(31→1 / 92→3 / 366→12),用于每月折算展示。
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import '../widgets/pangolin_icons.dart';
|
|||||||
import '../widgets/plan_card.dart';
|
import '../widgets/plan_card.dart';
|
||||||
import '../widgets/seg_switch.dart';
|
import '../widgets/seg_switch.dart';
|
||||||
|
|
||||||
|
/// 手写 firstWhere-or-null(避免仅为一次查找引入 package:collection 依赖)。
|
||||||
|
T? _firstWhereOrNull<T>(List<T> items, bool Function(T) test) {
|
||||||
|
for (final it in items) {
|
||||||
|
if (test(it)) return it;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
class PurchaseScreen extends ConsumerStatefulWidget {
|
class PurchaseScreen extends ConsumerStatefulWidget {
|
||||||
const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false});
|
const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false});
|
||||||
|
|
||||||
@@ -62,42 +70,58 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
|||||||
style: PangolinText.body.copyWith(color: c.fg3)),
|
style: PangolinText.body.copyWith(color: c.fg3)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (items) => ListView(
|
data: (items) {
|
||||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
final promoItem = _firstWhereOrNull(items, (i) => i.promo);
|
||||||
children: [
|
final planItems = items.where((i) => !i.promo).toList();
|
||||||
// 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新
|
final originalItem = _firstWhereOrNull(items, (i) => i.sku == 'pro_month');
|
||||||
Padding(
|
return ListView(
|
||||||
padding: const EdgeInsets.only(left: 2, bottom: 8),
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||||
child: Text(t.payChannel.toUpperCase(),
|
children: [
|
||||||
style: PangolinText.caption.copyWith(
|
// 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新
|
||||||
color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)),
|
|
||||||
),
|
|
||||||
SegSwitch(
|
|
||||||
options: [
|
|
||||||
(icon: PangolinIcons.globe, label: t.payChannelUsdt),
|
|
||||||
(icon: PangolinIcons.creditCard, label: t.payChannelCny),
|
|
||||||
],
|
|
||||||
selectedIndex: _channel == PayChannel.usdt ? 0 : 1,
|
|
||||||
onChanged: (i) => setState(
|
|
||||||
() => _channel = i == 0 ? PayChannel.usdt : PayChannel.cny),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
for (final it in items)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0),
|
padding: const EdgeInsets.only(left: 2, bottom: 8),
|
||||||
child: PlanCard(
|
child: Text(t.payChannel.toUpperCase(),
|
||||||
name: _name(it.sku),
|
style: PangolinText.caption.copyWith(
|
||||||
price: it.priceLabel(_channel),
|
color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)),
|
||||||
period: '${it.perMonthLabel(_channel)}${t.perMonthSuffix}',
|
|
||||||
features: t.featsPro,
|
|
||||||
ctaLabel: t.buyNow,
|
|
||||||
featured: it.sku == 'pro_year',
|
|
||||||
popularLabel: it.sku == 'pro_year' ? t.mostPopular : null,
|
|
||||||
onPressed: () => _buy(it),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
SegSwitch(
|
||||||
),
|
options: [
|
||||||
|
(icon: PangolinIcons.globe, label: t.payChannelUsdt),
|
||||||
|
(icon: PangolinIcons.creditCard, label: t.payChannelCny),
|
||||||
|
],
|
||||||
|
selectedIndex: _channel == PayChannel.usdt ? 0 : 1,
|
||||||
|
onChanged: (i) => setState(
|
||||||
|
() => _channel = i == 0 ? PayChannel.usdt : PayChannel.cny),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
if (promoItem != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: _PromoCard(
|
||||||
|
t: t,
|
||||||
|
channel: _channel,
|
||||||
|
item: promoItem,
|
||||||
|
originalItem: originalItem,
|
||||||
|
onPressed: () => _buy(promoItem),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final it in planItems)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0),
|
||||||
|
child: PlanCard(
|
||||||
|
name: _name(it.sku),
|
||||||
|
price: it.priceLabel(_channel),
|
||||||
|
period: '${it.perMonthLabel(_channel)}${t.perMonthSuffix}',
|
||||||
|
features: t.featsPro,
|
||||||
|
ctaLabel: t.buyNow,
|
||||||
|
featured: it.sku == 'pro_year',
|
||||||
|
popularLabel: it.sku == 'pro_year' ? t.mostPopular : null,
|
||||||
|
onPressed: () => _buy(it),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.embedded) return body;
|
if (widget.embedded) return body;
|
||||||
@@ -122,3 +146,104 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// _PromoCard — 全员优惠月付特殊卡片(横幅式,视觉上与普通 PlanCard 拉开层次)。
|
||||||
|
/// 徽章 + 大号现价(随渠道 ¥/$ 联动)+ 可选原价划线(以 pro_month 为参照)。
|
||||||
|
class _PromoCard extends StatelessWidget {
|
||||||
|
const _PromoCard({
|
||||||
|
required this.t,
|
||||||
|
required this.channel,
|
||||||
|
required this.item,
|
||||||
|
required this.originalItem,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppText t;
|
||||||
|
final PayChannel channel;
|
||||||
|
final PayCatalogItem item;
|
||||||
|
final PayCatalogItem? originalItem;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final originalLabel = originalItem?.priceLabel(channel);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [PangolinColors.clay500, PangolinColors.clay700],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(PangolinRadius.xl),
|
||||||
|
boxShadow: PangolinShadow.md,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: PangolinColors.white.withValues(alpha: 0.22),
|
||||||
|
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||||
|
),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Icon(PangolinIcons.zap, size: 13, color: PangolinColors.white),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(t.promoBadge,
|
||||||
|
style: PangolinText.caption.copyWith(
|
||||||
|
color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 11)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(t.proMonthly,
|
||||||
|
style: PangolinText.sm.copyWith(
|
||||||
|
color: PangolinColors.white.withValues(alpha: 0.85), fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Text(item.priceLabel(channel),
|
||||||
|
style: PangolinText.display.copyWith(color: PangolinColors.white, fontSize: 30)),
|
||||||
|
Text(t.perMonthSuffix,
|
||||||
|
style: PangolinText.caption.copyWith(
|
||||||
|
color: PangolinColors.white.withValues(alpha: 0.85), fontSize: 13)),
|
||||||
|
if (originalLabel != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(originalLabel,
|
||||||
|
style: PangolinText.sm.copyWith(
|
||||||
|
color: PangolinColors.white.withValues(alpha: 0.62),
|
||||||
|
fontSize: 14,
|
||||||
|
decoration: TextDecoration.lineThrough,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Material(
|
||||||
|
color: PangolinColors.white,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(
|
||||||
|
child: Text(t.buyNow,
|
||||||
|
style: PangolinText.sm.copyWith(
|
||||||
|
fontWeight: FontWeight.w700, fontSize: 14, color: PangolinColors.clay700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ const _items = [
|
|||||||
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY', priceUsdtMicro: 34990000),
|
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY', priceUsdtMicro: 34990000),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const _itemsWithPromo = [
|
||||||
|
PayCatalogItem(
|
||||||
|
sku: 'pro_month_promo', plan: 'pro', days: 31, priceMinor: 600, currency: 'CNY',
|
||||||
|
priceUsdtMicro: 1000000, promo: true),
|
||||||
|
..._items,
|
||||||
|
];
|
||||||
|
|
||||||
/// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。
|
/// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。
|
||||||
class _PollingFakePaymentApi implements PaymentApi {
|
class _PollingFakePaymentApi implements PaymentApi {
|
||||||
int statusCalls = 0;
|
int statusCalls = 0;
|
||||||
@@ -80,6 +87,29 @@ void main() {
|
|||||||
expect(find.text('¥199.99'), findsOneWidget);
|
expect(find.text('¥199.99'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('购买页:全员优惠月付特殊卡片(随渠道显价 + 原价划线)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(wrapThemed(
|
||||||
|
PurchaseScreen(t: t, embedded: true),
|
||||||
|
overrides: [payCatalogProvider.overrideWith((ref) async => _itemsWithPromo)],
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 普通 3 档照旧渲染 + 1 张特殊卡片(非 PlanCard)。
|
||||||
|
expect(find.byType(PlanCard), findsNWidgets(3));
|
||||||
|
expect(find.text(t.promoBadge), findsOneWidget);
|
||||||
|
// 默认 USDT → 现价 \$1.00;原价(pro_month)\$4.99 同时出现在划线原价 + 下方普通
|
||||||
|
// pro_month PlanCard(两处同值,断言 2 处)。
|
||||||
|
expect(find.text('\$1.00'), findsOneWidget);
|
||||||
|
expect(find.text('\$4.99'), findsNWidgets(2));
|
||||||
|
// 切到人民币 → 现价 ¥6.00;原价 ¥29.99 同理 2 处。
|
||||||
|
await tester.tap(find.text(t.payChannelCny));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('¥6.00'), findsOneWidget);
|
||||||
|
expect(find.text('¥29.99'), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async {
|
testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async {
|
||||||
final flow = PaymentFlowState(
|
final flow = PaymentFlowState(
|
||||||
phase: PaymentPhase.awaitingPayment,
|
phase: PaymentPhase.awaitingPayment,
|
||||||
|
|||||||
Reference in New Issue
Block a user