diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 842a5bd..a174cad 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -587,6 +587,8 @@ abstract class AppText { String get orderRebuy; String get payMethodNezha; // 请复制内容后在支付宝内打开 / Copy and open in Alipay String get orderStatusExpired; // 已过期 / Expired + String get promoBadge; // 超值月付 / Best value + String get promoOriginalPrice; // 原价 / Was // ── 邀请好友(Spec ②占位) ── String get inviteEntry; // 邀请好友得会员 / Invite friends, earn membership diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 0c9ed04..bb7405f 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -511,4 +511,8 @@ class StringsEn extends AppText { String get settingsGroupAbout => 'About'; @override String get orderStatusExpired => 'Expired'; + @override + String get promoBadge => 'Limited-time offer'; + @override + String get promoOriginalPrice => 'Was'; } diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index bec38c5..0e14f22 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -512,4 +512,8 @@ class StringsEs extends AppText { String get settingsGroupAbout => 'Acerca de'; @override String get orderStatusExpired => 'Caducado'; + @override + String get promoBadge => 'Oferta especial'; + @override + String get promoOriginalPrice => 'Antes'; } diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index 4ed678b..d480006 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -512,4 +512,8 @@ class StringsJa extends AppText { String get settingsGroupAbout => 'このアプリについて'; @override String get orderStatusExpired => '期限切れ'; + @override + String get promoBadge => '期間限定オファー'; + @override + String get promoOriginalPrice => '通常価格'; } diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 3d26f68..41bb946 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -512,4 +512,8 @@ class StringsKo extends AppText { String get settingsGroupAbout => '정보'; @override String get orderStatusExpired => '만료됨'; + @override + String get promoBadge => '한정 특가'; + @override + String get promoOriginalPrice => '정가'; } diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index e8c6f5e..95b6c97 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -512,4 +512,8 @@ class StringsRu extends AppText { String get settingsGroupAbout => 'О приложении'; @override String get orderStatusExpired => 'Истёк'; + @override + String get promoBadge => 'Спецпредложение'; + @override + String get promoOriginalPrice => 'Было'; } diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index bbfd42a..331283d 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -505,4 +505,8 @@ class StringsZh extends AppText { String get settingsGroupAbout => '关于'; @override String get orderStatusExpired => '已过期'; + @override + String get promoBadge => '限时优惠'; + @override + String get promoOriginalPrice => '原价'; } diff --git a/client/lib/models/payment.dart b/client/lib/models/payment.dart index f0a3eeb..b753ad8 100644 --- a/client/lib/models/payment.dart +++ b/client/lib/models/payment.dart @@ -18,6 +18,7 @@ class PayCatalogItem { required this.priceMinor, required this.currency, this.priceUsdtMicro = 0, + this.promo = false, }); final String sku; @@ -26,6 +27,7 @@ class PayCatalogItem { final int priceMinor; // CNY 分(展示) final String currency; // CNY final int priceUsdtMicro; // USDT 微元(展示;各币种一口价) + final bool promo; // 全员可见的限时优惠档(如 pro_month_promo) factory PayCatalogItem.fromJson(Map j) => PayCatalogItem( sku: j['sku'] as String, @@ -34,6 +36,7 @@ class PayCatalogItem { priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0, currency: j['currency'] as String? ?? 'CNY', priceUsdtMicro: (j['price_usdt_micro'] as num?)?.toInt() ?? 0, + promo: j['promo'] as bool? ?? false, ); /// 约当月数(31→1 / 92→3 / 366→12),用于每月折算展示。 diff --git a/client/lib/screens/purchase_page.dart b/client/lib/screens/purchase_page.dart index aca11f3..f984310 100644 --- a/client/lib/screens/purchase_page.dart +++ b/client/lib/screens/purchase_page.dart @@ -13,6 +13,14 @@ import '../widgets/pangolin_icons.dart'; import '../widgets/plan_card.dart'; import '../widgets/seg_switch.dart'; +/// 手写 firstWhere-or-null(避免仅为一次查找引入 package:collection 依赖)。 +T? _firstWhereOrNull(List items, bool Function(T) test) { + for (final it in items) { + if (test(it)) return it; + } + return null; +} + class PurchaseScreen extends ConsumerStatefulWidget { const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false}); @@ -62,42 +70,58 @@ class _PurchaseScreenState extends ConsumerState { style: PangolinText.body.copyWith(color: c.fg3)), ), ), - data: (items) => ListView( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), - children: [ - // 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新 - Padding( - padding: const EdgeInsets.only(left: 2, bottom: 8), - child: Text(t.payChannel.toUpperCase(), - style: PangolinText.caption.copyWith( - 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) + data: (items) { + final promoItem = _firstWhereOrNull(items, (i) => i.promo); + final planItems = items.where((i) => !i.promo).toList(); + final originalItem = _firstWhereOrNull(items, (i) => i.sku == 'pro_month'); + return ListView( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), + children: [ + // 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新 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), - ), + padding: const EdgeInsets.only(left: 2, bottom: 8), + child: Text(t.payChannel.toUpperCase(), + style: PangolinText.caption.copyWith( + 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), + 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; @@ -122,3 +146,104 @@ class _PurchaseScreenState extends ConsumerState { ); } } + +/// _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)), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/client/test/widget/payment_pages_test.dart b/client/test/widget/payment_pages_test.dart index edabbe3..58bcd93 100644 --- a/client/test/widget/payment_pages_test.dart +++ b/client/test/widget/payment_pages_test.dart @@ -18,6 +18,13 @@ const _items = [ 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 调用次数,其余按最小实现返回。 class _PollingFakePaymentApi implements PaymentApi { int statusCalls = 0; @@ -80,6 +87,29 @@ void main() { 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 { final flow = PaymentFlowState( phase: PaymentPhase.awaitingPayment,