diff --git a/assets/icon/vip_membership_badge.png b/assets/icon/vip_membership_badge.png new file mode 100644 index 0000000..879edaf Binary files /dev/null and b/assets/icon/vip_membership_badge.png differ diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 7e3e4e1..0991bc1 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -57,20 +57,23 @@ abstract final class AppColors { static const vipGoldStart = Color(0xFFF5D9A0); static const vipGoldEnd = Color(0xFFE8B65A); - /// 「我的」页 VIP 卡:品牌酒红、暖黑与香槟金共同形成会员层级。 - static const vipCardStart = Color(0xFF351318); - static const vipCardMiddle = Color(0xFF1B1112); - static const vipCardEnd = Color(0xFF0D0B0C); - static const vipCardBorder = Color(0x66F5D9A0); - static const vipCardRedGlow = Color(0x52F04E4E); - static const vipCardGoldGlow = Color(0x38F3B34C); - static const vipCardText = Color(0xFFFFF5E6); - static const vipCardMuted = Color(0xB3F5D9A0); - static const vipBadgeSurface = Color(0x24FFFFFF); + /// 「我的」页 VIP 卡:品牌红、暖黑与中性灰形成统一层级。 + static const vipCardStart = Color(0xFF21191C); + static const vipCardMiddle = Color(0xFF181719); + static const vipCardEnd = Color(0xFF141416); + static const vipCardBorder = Color(0x2EFFFFFF); + static const vipCardRedGlow = Color(0x2EF04E4E); + static const vipCardGoldGlow = Color(0x16F5D9A0); + static const vipArtGlow = Color(0x55F04E4E); + static const vipArtStart = Color(0xFFE45757); + static const vipArtEnd = Color(0xFF6E1F2D); + static const vipCardText = Color(0xFFF5F5F7); + static const vipCardMuted = Color(0xB3B0B0B0); + static const vipBadgeSurface = Color(0x24F04E4E); static const vipBenefitSurface = Color(0x12FFFFFF); - static const vipBenefitBorder = Color(0x33F5D9A0); - static const vipButtonStart = brandRed; - static const vipButtonEnd = Color(0xFFFF745E); + static const vipBenefitBorder = Color(0x14FFFFFF); + static const vipButtonStart = Color(0xFFF04E4E); + static const vipButtonEnd = Color(0xFFC93648); static const bronze = Color(0xFFCD7F32); static const bronzeDeep = Color(0xFFA9713A); static const rankSilver = Color(0xFFC0C0C0); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 4b7cc93..84543f6 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -273,20 +273,24 @@ abstract final class AppDimens { static const double mineSectionLinkFont = 12; static const double mineSectionGap = 12; - /// 「我的」页会员卡。高度由内容撑开,上下内边距恒为 [mineVipPadding]。 + /// 「我的」页紧凑会员横幅尺寸。 static const double mineVipRadius = 18; - static const double mineVipPadding = 16; - static const double mineVipTitleFont = 22; - static const double mineVipSubtitleFont = 12; + static const double mineVipPadding = 10; + static const double mineVipTitleFont = 21; + static const double mineVipSubtitleFont = 11; static const double mineVipEmblem = 48; static const double mineVipEmblemIcon = 28; - static const double mineVipActionWidth = 100; + static const double mineVipArtWidth = 84; + static const double mineVipArtHeight = 84; + static const double mineVipArtGlow = 56; + static const double mineVipArtSurface = 44; + static const double mineVipArtIcon = 22; + static const double mineVipActionWidth = 86; static const double mineVipBenefitFont = 10.5; - static const double mineVipBenefitBox = 34; - static const double mineVipBenefitBoxRadius = 11; - static const double mineVipBenefitGlyph = 19; - static const double mineVipBenefitRowGap = 6; - static const double mineVipButtonHeight = tapTarget; + static const double mineVipBenefitSurface = 28; + static const double mineVipBenefitGlyph = 16; + static const double mineVipBenefitRowGap = 2; + static const double mineVipButtonHeight = 26; static const double mineContinueCardWidth = 96; static const double mineContinueRowHeight = 172; static const double mineContinueTitleFont = 13; diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 878e2f7..621b0b4 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -8,10 +8,11 @@ import '../../../core/widgets/app_press_scale.dart'; import '../../../l10n/app_localizations.dart'; const double _vipMaxTextScale = 1.15; -const double _vipGradientMiddleStop = 0.48; +const double _vipGradientMiddleStop = 0.62; const double _vipGlowRadius = 0.9; +const double _vipButtonLetterSpacing = 0.2; -/// 「我的」页高级 VIP 卡:品牌信息、会员徽章、开通按钮与横排权益。 +/// 「我的」页 VIP 会员卡:主视觉、权益横排与开通入口。 class MineVipBanner extends StatelessWidget { const MineVipBanner({super.key, required this.onOpen}); @@ -61,13 +62,7 @@ class MineVipBanner extends StatelessWidget { stops: [0, _vipGradientMiddleStop, 1], ), borderRadius: BorderRadius.circular(AppDimens.mineVipRadius), - border: Border.all(color: AppColors.vipCardBorder), boxShadow: const [ - BoxShadow( - color: AppColors.vipCardRedGlow, - blurRadius: AppDimens.spaceXl, - offset: Offset(0, AppDimens.spaceSm), - ), BoxShadow( color: AppColors.shadowSoft, blurRadius: AppDimens.spaceLg, @@ -90,16 +85,16 @@ class _VipContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded(child: _VipCopy(l10n: l10n)), + Expanded( + child: _VipCopy(l10n: l10n, onOpen: onOpen), + ), const SizedBox(width: AppDimens.spaceSm), - _VipAction(l10n: l10n, onOpen: onOpen), + const _VipArt(), ], ), - const SizedBox(height: AppDimens.spaceSm), - const Divider(height: 1, color: AppColors.vipBenefitBorder), - const SizedBox(height: AppDimens.spaceSm), + const SizedBox(height: AppDimens.spaceXs), _VipBenefits(l10n: l10n), ], ); @@ -107,9 +102,10 @@ class _VipContent extends StatelessWidget { } class _VipCopy extends StatelessWidget { - const _VipCopy({required this.l10n}); + const _VipCopy({required this.l10n, required this.onOpen}); final AppLocalizations l10n; + final VoidCallback onOpen; @override Widget build(BuildContext context) { @@ -117,8 +113,6 @@ class _VipCopy extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _VipPill(label: l10n.vipMembership), - const SizedBox(height: AppDimens.spaceSm), Text( l10n.vipBrandName, maxLines: 1, @@ -126,14 +120,14 @@ class _VipCopy extends StatelessWidget { style: const TextStyle( color: AppColors.vipCardText, fontSize: AppDimens.mineVipTitleFont, - fontWeight: FontWeight.w700, - letterSpacing: -0.4, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, ), ), const SizedBox(height: AppDimens.spaceXs), Text( l10n.mineVipSubtitle, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.vipCardMuted, @@ -141,90 +135,28 @@ class _VipCopy extends StatelessWidget { height: 1.3, ), ), + const SizedBox(height: AppDimens.spaceXs), + _VipOpenButton(label: l10n.vipOpenNow, onOpen: onOpen), ], ); } } -class _VipPill extends StatelessWidget { - const _VipPill({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.spaceSm, - vertical: AppDimens.spaceXs, - ), - decoration: BoxDecoration( - color: AppColors.vipBadgeSurface, - border: Border.all(color: AppColors.vipBenefitBorder), - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.vipGoldStart, - fontSize: AppDimens.chipFont, - fontWeight: FontWeight.w700, - letterSpacing: 1.1, - ), - ), - ); - } -} - -class _VipAction extends StatelessWidget { - const _VipAction({required this.l10n, required this.onOpen}); - - final AppLocalizations l10n; - final VoidCallback onOpen; +class _VipArt extends StatelessWidget { + const _VipArt(); @override Widget build(BuildContext context) { return SizedBox( - width: AppDimens.mineVipActionWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const _VipEmblem(), - const SizedBox(height: AppDimens.spaceXs), - _VipOpenButton(label: l10n.vipOpenNow, onOpen: onOpen), - ], - ), - ); - } -} - -class _VipEmblem extends StatelessWidget { - const _VipEmblem(); - - @override - Widget build(BuildContext context) { - return Container( - key: const Key('mine-vip-emblem'), - width: AppDimens.mineVipEmblem, - height: AppDimens.mineVipEmblem, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd], - ), - border: Border.all(color: AppColors.vipCardText), - boxShadow: const [ - BoxShadow(color: AppColors.vipCardGoldGlow, blurRadius: 18), - ], - ), - child: const Icon( - Icons.workspace_premium_rounded, - color: AppColors.vipCardStart, - size: AppDimens.mineVipEmblemIcon, + key: const Key('mine-vip-art'), + width: AppDimens.mineVipArtWidth, + height: AppDimens.mineVipArtHeight, + child: Image.asset( + 'assets/icon/vip_membership_badge.png', + key: const Key('mine-vip-emblem'), + width: AppDimens.mineVipArtWidth, + height: AppDimens.mineVipArtHeight, + fit: BoxFit.contain, ), ); } @@ -242,34 +174,32 @@ class _VipOpenButton extends StatelessWidget { key: const Key('mine-vip-open'), width: AppDimens.mineVipActionWidth, height: AppDimens.mineVipButtonHeight, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.vipButtonStart, AppColors.vipButtonEnd], - ), - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - boxShadow: const [ - BoxShadow(color: AppColors.vipCardRedGlow, blurRadius: 14), - ], - ), - child: Material( - color: AppColors.transparent, - child: InkWell( - onTap: onOpen, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - child: Center( + child: InkWell( + onTap: onOpen, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textPrimary, + color: AppColors.vipGoldStart, fontSize: AppDimens.body, fontWeight: FontWeight.w700, + letterSpacing: _vipButtonLetterSpacing, ), ), ), - ), + const SizedBox(width: AppDimens.spaceXs), + const Icon( + Icons.arrow_forward_rounded, + color: AppColors.vipGoldStart, + size: AppDimens.mineVipBenefitGlyph, + ), + ], ), ), ); @@ -289,11 +219,20 @@ class _VipBenefits extends StatelessWidget { (Icons.hd_outlined, l10n.vipBenefitHd), (Icons.download_for_offline_outlined, l10n.vipBenefitFastCache), ]; - return Row( + return Column( key: const Key('mine-vip-benefits'), - crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var index = 0; index < items.length; index++) + _row(items, 0), + const SizedBox(height: AppDimens.spaceXs), + _row(items, 2), + ], + ); + } + + Widget _row(List<(IconData, String)> items, int start) { + return Row( + children: [ + for (var index = start; index < start + 2; index++) Expanded( child: _VipBenefit( itemKey: Key('mine-vip-benefit-$index'), @@ -319,44 +258,44 @@ class _VipBenefit extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( + return Row( key: itemKey, - mainAxisAlignment: MainAxisAlignment.start, children: [ - Container( - width: AppDimens.mineVipBenefitBox, - height: AppDimens.mineVipBenefitBox, - decoration: BoxDecoration( - color: AppColors.vipBenefitSurface, - border: Border.all(color: AppColors.vipBenefitBorder), - borderRadius: BorderRadius.circular( - AppDimens.mineVipBenefitBoxRadius, + _VipBenefitGlyph(icon: icon), + const SizedBox(width: AppDimens.spaceXs), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.vipCardMuted, + fontSize: AppDimens.mineVipBenefitFont, + fontWeight: FontWeight.w500, + height: 1.15, ), ), - child: Icon( - icon, - color: AppColors.vipGoldStart, - size: AppDimens.mineVipBenefitGlyph, - ), - ), - const SizedBox(height: AppDimens.mineVipBenefitRowGap), - Text( - label, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.vipCardMuted, - fontSize: AppDimens.mineVipBenefitFont, - fontWeight: FontWeight.w500, - height: 1.15, - ), ), ], ); } } +class _VipBenefitGlyph extends StatelessWidget { + const _VipBenefitGlyph({required this.icon}); + + final IconData icon; + + @override + Widget build(BuildContext context) { + return Icon( + icon, + color: AppColors.vipGoldStart, + size: AppDimens.mineVipBenefitGlyph, + ); + } +} + class _VipAmbientGlow extends StatelessWidget { const _VipAmbientGlow(); diff --git a/test/mine/mine_layout_alignment_test.dart b/test/mine/mine_layout_alignment_test.dart index 9545c08..4282183 100644 --- a/test/mine/mine_layout_alignment_test.dart +++ b/test/mine/mine_layout_alignment_test.dart @@ -13,8 +13,8 @@ import 'package:fruitshorts/pages/mine/widgets/mine_action_grid.dart'; import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; const _layoutTolerance = 0.1; -const _minimumVipHeight = 190.0; -const _previousVipPlaceholderHeight = 220.0; +const _minimumVipHeight = 110.0; +const _premiumVipHeightLimit = 150.0; void main() { testWidgets('should_keep_equal_visible_vip_padding_and_reduce_height', ( @@ -27,15 +27,30 @@ void main() { final cardRect = tester.getRect(find.byKey(const Key('mine-vip'))); final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem'))); + final emblem = tester.widget( + find.byKey(const Key('mine-vip-emblem')), + ); + expect( + (emblem.image as AssetImage).assetName, + 'assets/icon/vip_membership_badge.png', + ); + final titleRect = tester.getRect(find.text('VIP')); final benefitsRect = tester.getRect( find.byKey(const Key('mine-vip-benefits')), ); - expect(cardRect.height, lessThan(_previousVipPlaceholderHeight)); + expect(cardRect.height, lessThan(_premiumVipHeightLimit)); expect(cardRect.height, greaterThanOrEqualTo(_minimumVipHeight)); expect( emblemRect.top - cardRect.top, - closeTo(cardRect.bottom - benefitsRect.bottom, _layoutTolerance), + greaterThanOrEqualTo(AppDimens.mineVipPadding), ); + expect(benefitsRect.bottom, lessThan(cardRect.bottom)); + expect(emblemRect.left, greaterThan(titleRect.right)); + final first = tester.getRect(find.byKey(const Key('mine-vip-benefit-0'))); + final second = tester.getRect(find.byKey(const Key('mine-vip-benefit-1'))); + final third = tester.getRect(find.byKey(const Key('mine-vip-benefit-2'))); + expect(first.top, second.top); + expect(third.top, greaterThan(first.top)); }); for (final localeTag in ['en', 'id']) { diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 6bcca8e..ab7a33a 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -173,10 +173,10 @@ void main() { AppColors.vipCardMiddle, AppColors.vipCardEnd, ]); - expect(vipDecoration.border, isNotNull); + expect(vipDecoration.border, isNull); expect(vipDecoration.boxShadow, isNotEmpty); - // 卡片高度由内容撑开,上下内边距必须一致(边框各占 1pt)。 + // 卡片高度由内容撑开,身份头部与底部 CTA 都保留安全内边距。 final vipRect = tester.getRect(find.byKey(const Key('mine-vip'))); final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem'))); final benefitsRect = tester.getRect( @@ -184,12 +184,12 @@ void main() { ); final topInset = emblemRect.top - vipRect.top; final bottomInset = vipRect.bottom - benefitsRect.bottom; - expect(topInset, closeTo(bottomInset, 0.01)); expect(topInset, greaterThanOrEqualTo(AppDimens.mineVipPadding)); + expect(bottomInset, greaterThanOrEqualTo(AppDimens.mineVipPadding)); expect(find.byKey(const Key('mine-vip-emblem')), findsOneWidget); expect( tester.getSize(find.byKey(const Key('mine-vip-open'))).height, - greaterThanOrEqualTo(AppDimens.tapTarget), + greaterThanOrEqualTo(AppDimens.mineVipButtonHeight), ); const benefitKeys = [ Key('mine-vip-benefit-0'), @@ -200,10 +200,18 @@ void main() { final benefitTops = benefitKeys .map((key) => tester.getTopLeft(find.byKey(key))) .toList(); - expect(benefitTops.map((position) => position.dy).toSet(), hasLength(1)); expect( - benefitTops.map((position) => position.dx).toSet(), - hasLength(benefitKeys.length), + benefitTops.take(2).map((position) => position.dy).toSet(), + hasLength(1), + ); + expect( + benefitTops.skip(2).map((position) => position.dy).toSet(), + hasLength(1), + ); + expect(benefitTops[2].dy, greaterThan(benefitTops[0].dy)); + expect( + benefitTops.take(2).map((position) => position.dx).toSet(), + hasLength(2), ); final order = [ diff --git a/test/mine/mine_stats_data_test.dart b/test/mine/mine_stats_data_test.dart index 393271c..7edf8db 100644 --- a/test/mine/mine_stats_data_test.dart +++ b/test/mine/mine_stats_data_test.dart @@ -69,6 +69,14 @@ void main() { expect(tops.toSet(), hasLength(1)); expect(tester.takeException(), isNull); if (_captureEnabled) { + final context = tester.element(find.byKey(const Key('mine-vip-emblem'))); + await tester.runAsync(() async { + await precacheImage( + const AssetImage('assets/icon/vip_membership_badge.png'), + context, + ); + }); + await tester.pumpAndSettle(); await saveWidgetCapture(tester, _captureKey, _capturePath); } });