From 53e8c924a6c2110641c99c5e5332539eec2ad28c Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Fri, 30 Oct 2026 17:14:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(mine):=20=E9=87=8D=E5=81=9A=E6=88=91?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E4=BC=9A=E5=91=98=E5=8D=A1=E4=B8=8E=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=AB=E6=A0=BC=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 29 +- lib/core/theme/app_dimens.dart | 50 +- lib/pages/mine/mine_view.dart | 191 +++--- lib/pages/mine/widgets/mine_action_grid.dart | 270 ++++++++ lib/pages/mine/widgets/mine_menu.dart | 614 ++++++++++--------- lib/pages/mine/widgets/mine_stat_icon.dart | 218 +++++++ lib/pages/mine/widgets/mine_stats_panel.dart | 267 ++++++-- test/mine/mine_layout_alignment_test.dart | 155 +++++ test/mine/mine_preview_layout_test.dart | 136 +++- test/mine/mine_stats_data_test.dart | 324 ++++++++++ test/mine/mine_stats_panel_test.dart | 168 +++++ test/support/widget_capture.dart | 60 ++ 12 files changed, 1969 insertions(+), 513 deletions(-) create mode 100644 lib/pages/mine/widgets/mine_action_grid.dart create mode 100644 lib/pages/mine/widgets/mine_stat_icon.dart create mode 100644 test/mine/mine_layout_alignment_test.dart create mode 100644 test/mine/mine_stats_data_test.dart create mode 100644 test/mine/mine_stats_panel_test.dart create mode 100644 test/support/widget_capture.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 49d2071..d9376b4 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -48,10 +48,20 @@ abstract final class AppColors { static const vipGoldStart = Color(0xFFF5D9A0); static const vipGoldEnd = Color(0xFFE8B65A); - /// 「我的」页 VIP 卡的浅香槟金(见设计稿,接近纯色)。 - static const vipCardStart = Color(0xFFF6E8C6); - static const vipCardEnd = Color(0xFFEDD9A8); - static const vipBadgeSurface = Color(0x1F3D2408); + /// 「我的」页 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); + static const vipBenefitSurface = Color(0x12FFFFFF); + static const vipBenefitBorder = Color(0x33F5D9A0); + static const vipButtonStart = brandRed; + static const vipButtonEnd = Color(0xFFFF745E); static const bronze = Color(0xFFCD7F32); static const bronzeDeep = Color(0xFFA9713A); static const rankSilver = Color(0xFFC0C0C0); @@ -113,9 +123,18 @@ abstract final class AppColors { static const mineGridSurface = Color(0x99101010); static const minePanelSurface = Color(0xFF101010); static const mineMoreDivider = Color(0xFF1C1C1E); - static const mineGridDivider = Color(0xFF2A2A2A); + static const mineGridDivider = Color(0x10FFFFFF); static const mineGridIconSurface = Color(0xFF1C1C1E); static const mineGridLabel = Color(0xFFE6E6EB); + static const mineGridIconAccent = brandRed; + + /// 个人数据图标:品牌红的亮面、暗部与暖白反光。 + static const mineStatHighlight = Color(0xFFFFAAA3); + static const mineStatShade = Color(0xFFAF263C); + static const mineStatDepth = Color(0xFF681D2B); + static const mineStatGlint = Color(0xFFFFE8E2); + static const mineStatEdge = Color(0x80FFD1CB); + static const mineStatGlow = Color(0x24F04E4E); static const mineIconWallet = Color(0xFFF5C451); static const mineIconDownloads = Color(0xFF4DA3FF); static const mineIconHistory = Color(0xFF4ADE80); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 396bac3..7e4e72a 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -141,7 +141,7 @@ abstract final class AppDimens { static const double homePrimarySecondaryGap = 8; static const double homeSecondaryGridGap = 10; static const double homeHeaderIcon = 22; - static const double homeActionSlot = 40; + static const double homeActionSlot = tapTarget; static const double homeActionIcon = 30; static const double homeActionGap = 6; @@ -184,7 +184,7 @@ abstract final class AppDimens { static const double followingTabFont = 18; static const double followingTabGap = 24; static const double followingEditIconSize = 20; - static const double followingEditHitSize = 40; + static const double followingEditHitSize = tapTarget; static const double followingAddIconSize = 20; static const double followingAddIconStroke = 1.5; static const double followingGridGap = 6; @@ -269,32 +269,44 @@ abstract final class AppDimens { static const double mineGridIconSurface = 44; static const double mineGridIcon = 22; static const double mineGridLabel = 12; + static const double mineGridLabelLineHeight = 1.15; static const double mineGridDividerWidth = 1; + + /// 宫格内部分隔线:0.5pt 发丝线,深色底上比 1pt 更轻。 + static const double mineGridHairline = 0.5; + static const int mineGridRows = 2; + static const double mineGridHeight = + mineGridCellHeight * mineGridRows + mineGridHairline; static const double mineSectionTitleFont = 16; static const double mineSectionLinkFont = 12; static const double mineSectionGap = 12; - /// 「我的」页新版:快捷图标行与服务列表。 - static const double mineQuickActionIcon = 28; - static const double mineQuickActionLabelFont = 14; - static const double mineQuickActionGap = 8; - static const double mineServiceRowHeight = 56; - static const double mineServiceIcon = 24; - static const double mineServiceTitleFont = 16; - static const double mineServiceValueFont = 14; - static const double mineVipBenefitFont = 14; - static const double mineVipBenefitIcon = 20; - static const double mineVipBenefitBox = 30; - static const double mineVipBenefitBoxRadius = 7; - static const double mineVipBenefitGlyph = 17; - static const double mineVipBenefitRowGap = 4; - static const double mineVipButtonHeight = 36; + /// 「我的」页会员卡。高度由内容撑开,上下内边距恒为 [mineVipPadding]。 + static const double mineVipRadius = 18; + static const double mineVipPadding = 16; + static const double mineVipTitleFont = 22; + static const double mineVipSubtitleFont = 12; + static const double mineVipEmblem = 48; + static const double mineVipEmblemIcon = 28; + static const double mineVipActionWidth = 100; + 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 mineContinueCardWidth = 96; static const double mineContinueRowHeight = 172; static const double mineContinueTitleFont = 13; static const double mineContinueMetaFont = 11; - static const double mineStatsValueFont = 18; - static const double mineStatsLabelFont = 11; + static const double mineStatsValueFont = 24; + static const double mineStatsLabelFont = 12; + static const double mineStatsIcon = 42; + static const double mineStatsMinHeight = 104; + static const double mineStatsDividerHeight = 32; + static const double mineStatsDividerWidth = 0.5; + static const double mineStatsValueLineHeight = 1.1; + static const double mineStatsLabelLineHeight = 1.25; static const double minePanelPadding = 12; static const double mineMoreRowHeight = 52; static const double mineMoreFont = 15; diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 2e616b4..ba8df22 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -31,8 +31,10 @@ import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; import 'mine_types.dart'; +import 'widgets/mine_action_grid.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; +import 'widgets/mine_stats_panel.dart'; /// K28-B 我的页。 class MyPage extends StatefulWidget { @@ -84,7 +86,9 @@ class _MyPageState extends State { _activationRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _activationRefreshScheduled = false; - if (mounted) unawaited(_favorites.load()); + if (!mounted) return; + unawaited(_favorites.load()); + _refreshWallet(onlyIfMissing: true); }); } @@ -96,9 +100,17 @@ class _MyPageState extends State { if (!mounted || !widget.active) return; logic.syncPage(active: true, wasActive: false); unawaited(_favorites.load()); + _refreshWallet(); }); } + void _refreshWallet({bool onlyIfMissing = false}) { + if (!_auth.isSignedIn) return; + final wallet = context.read(); + if (wallet.loading || (onlyIfMissing && wallet.snapshot != null)) return; + unawaited(wallet.load()); + } + void _onAuthChanged() { final signedIn = _auth.isSignedIn; if (signedIn == _wasSignedIn) return; @@ -165,78 +177,62 @@ class _MyPageState extends State { } Widget _body(BuildContext context) { - final l10n = AppLocalizations.of(context)!; return ListView( key: const Key('mine-page-scroll'), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - child: MineHeader( + _section( + MineHeader( onLogin: _openLogin, onEdit: () => _push(const AccountProfilePage()), ), ), + const SizedBox(height: AppDimens.spaceSm), + _section(_statsPanel()), const SizedBox(height: AppDimens.spaceMd), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - child: MineVipBanner( - onOpen: () => _openDestination(MineDestination.vip), - ), - ), - const SizedBox(height: AppDimens.mineSectionGap), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - child: MineQuickActions( - onWallet: () => _openDestination(MineDestination.wallet), - onFavorites: () => _push(const FavoritesPage()), - onDownloads: () => _openDestination(MineDestination.downloads), - onHistory: () => _openDestination(MineDestination.history), - ), + _section( + MineVipBanner(onOpen: () => _openDestination(MineDestination.vip)), ), const SizedBox(height: AppDimens.mineSectionGap), Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.mineGridHorizontal, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.moreServices, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.mineSectionTitle, - ), - ), - const SizedBox(height: AppDimens.spaceSm), - MineServiceList( - onOpen: _openDestination, - onLanguage: _openLanguage, - languageName: _languageName(context), - ), - ], + child: MineActionGrid( + primaryActions: MinePrimaryActions( + onWallet: () => _openDestination(MineDestination.wallet), + onFavorites: () => unawaited(_openFavoritesPage()), + onDownloads: () => _openDestination(MineDestination.downloads), + onHistory: () => _openDestination(MineDestination.history), + ), + serviceActions: MineServiceActions( + onOpen: _openDestination, + onLanguage: _openLanguage, + ), ), ), ], ); } - /// 语言设置行右侧展示的当前语言名。 - String _languageName(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final code = context.read().locale?.languageCode; - return switch (code) { - 'en' => l10n.english, - 'id' => l10n.bahasaIndonesia, - _ => l10n.simplifiedChinese, - }; + Widget _section(Widget child) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + child: child, + ); + } + + Widget _statsPanel() { + return MineStatsSection( + watchRecordCount: logic.recentError == null + ? logic.watchRecordTotal + : null, + actions: MineStatsActions( + onWallet: () => _openDestination(MineDestination.wallet), + onHistory: () => _openDestination(MineDestination.history), + onFavorites: () => unawaited(_openFavoritesPage()), + ), + ); } void _openDestination(MineDestination destination) { @@ -269,6 +265,11 @@ class _MyPageState extends State { if (mounted) unawaited(logic.refreshRecent()); } + Future _openFavoritesPage() async { + await _push(const FavoritesPage()); + if (mounted) unawaited(_favorites.load()); + } + Future _push(Widget page) { return Navigator.of( context, @@ -373,56 +374,56 @@ class _LanguageSheet extends StatelessWidget { type: MaterialType.transparency, clipBehavior: Clip.antiAlias, child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - l10n.language, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 20, - fontWeight: FontWeight.w700, + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.language, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w700, + ), ), ), - ), - IconButton( - tooltip: l10n.close, - onPressed: () => Navigator.pop(context), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, + IconButton( + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + icon: const Icon(Icons.close_rounded, size: 20), ), - icon: const Icon(Icons.close_rounded, size: 20), - ), - ], - ), - for (final option in options) - ListTile( - key: option.$2, - contentPadding: const EdgeInsets.symmetric(horizontal: 4), - title: Text(option.$3), - trailing: selected?.languageCode == option.$1.languageCode - ? const Icon( - Icons.check_rounded, - color: AppColors.textPrimary, - ) - : null, - onTap: () => Navigator.pop(context, option.$1), + ], ), - ], + for (final option in options) + ListTile( + key: option.$2, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + title: Text(option.$3), + trailing: selected?.languageCode == option.$1.languageCode + ? const Icon( + Icons.check_rounded, + color: AppColors.textPrimary, + ) + : null, + onTap: () => Navigator.pop(context, option.$1), + ), + ], + ), ), ), ), - ), ); } } diff --git a/lib/pages/mine/widgets/mine_action_grid.dart b/lib/pages/mine/widgets/mine_action_grid.dart new file mode 100644 index 0000000..59c1230 --- /dev/null +++ b/lib/pages/mine/widgets/mine_action_grid.dart @@ -0,0 +1,270 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; +import '../mine_types.dart'; + +const int _gridColumnCount = 4; +const double _gridMaxTextScale = 1.2; + +/// 「我的」页统一操作宫格:常用入口与服务入口按 4×2 排列。 +class MineActionGrid extends StatelessWidget { + const MineActionGrid({ + super.key, + required this.primaryActions, + required this.serviceActions, + }); + + final MinePrimaryActions primaryActions; + final MineServiceActions serviceActions; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final items = [..._primaryItems(l10n), ..._serviceItems(l10n)]; + return SizedBox( + height: AppDimens.mineGridHeight, + child: DecoratedBox( + key: const Key('mine-menu'), + decoration: BoxDecoration( + color: AppColors.mineGridSurface, + border: Border.all(color: AppColors.contentBorder), + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: _gridMaxTextScale, + child: Column( + children: [ + _row(items.take(_gridColumnCount).toList()), + const _MineHorizontalDivider(), + _row(items.skip(_gridColumnCount).toList()), + ], + ), + ), + ), + ), + ); + } + + List<_MineActionItem> _primaryItems(AppLocalizations l10n) { + return [ + _MineActionItem( + key: const Key('mine-wallet'), + icon: Icons.account_balance_wallet_outlined, + label: l10n.wallet, + onTap: primaryActions.onWallet, + ), + _MineActionItem( + key: const Key('mine-favorites'), + icon: Icons.star_outline_rounded, + label: l10n.myFavorites, + onTap: primaryActions.onFavorites, + ), + _MineActionItem( + key: const Key('mine-downloads'), + icon: Icons.download_for_offline_outlined, + label: l10n.offlineDownloads, + onTap: primaryActions.onDownloads, + ), + _MineActionItem( + key: const Key('mine-history'), + icon: Icons.history_rounded, + label: l10n.watchHistory, + onTap: primaryActions.onHistory, + ), + ]; + } + + List<_MineActionItem> _serviceItems(AppLocalizations l10n) { + return [ + _MineActionItem( + key: const Key('mine-help'), + icon: Icons.support_agent_outlined, + label: l10n.contactSupport, + onTap: () => serviceActions.onOpen(MineDestination.help), + ), + _MineActionItem( + key: const Key('mine-language'), + icon: Icons.language_rounded, + label: l10n.language, + onTap: serviceActions.onLanguage, + ), + _MineActionItem( + key: const Key('mine-settings'), + icon: Icons.settings_outlined, + label: l10n.settings, + onTap: () => serviceActions.onOpen(MineDestination.settings), + ), + _MineActionItem( + key: const Key('mine-notices'), + icon: Icons.notifications_none_rounded, + label: l10n.notices, + onTap: () => serviceActions.onOpen(MineDestination.notices), + ), + ]; + } + + Widget _row(List<_MineActionItem> items) { + return SizedBox( + height: AppDimens.mineGridCellHeight, + child: Row( + children: [ + for (var index = 0; index < items.length; index++) ...[ + Expanded(child: _MineActionCell(item: items[index])), + if (index < items.length - 1) const _MineVerticalDivider(), + ], + ], + ), + ); + } +} + +/// 宫格第一排的常用功能回调。 +class MinePrimaryActions { + const MinePrimaryActions({ + required this.onWallet, + required this.onFavorites, + required this.onDownloads, + required this.onHistory, + }); + + final VoidCallback onWallet; + final VoidCallback onFavorites; + final VoidCallback onDownloads; + final VoidCallback onHistory; +} + +/// 宫格第二排的服务功能回调。 +class MineServiceActions { + const MineServiceActions({required this.onOpen, required this.onLanguage}); + + final ValueChanged onOpen; + final VoidCallback onLanguage; +} + +class _MineActionItem { + const _MineActionItem({ + required this.key, + required this.icon, + required this.label, + required this.onTap, + }); + + final Key key; + final IconData icon; + final String label; + final VoidCallback onTap; + + Key get iconSurfaceKey => + ValueKey('${(key as ValueKey).value}-icon-surface'); +} + +class _MineActionCell extends StatelessWidget { + const _MineActionCell({required this.item}); + + final _MineActionItem item; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.transparent, + child: InkWell( + key: item.key, + onTap: item.onTap, + child: Semantics( + button: true, + label: item.label, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _iconSurface(), + const SizedBox(height: AppDimens.spaceSm), + _label(), + ], + ), + ), + ), + ); + } + + Widget _iconSurface() { + return Container( + key: item.iconSurfaceKey, + width: AppDimens.mineGridIconSurface, + height: AppDimens.mineGridIconSurface, + decoration: BoxDecoration( + gradient: const RadialGradient( + center: Alignment(-0.35, -0.45), + radius: 0.95, + colors: [ + AppColors.mineOutline, + AppColors.contentChip, + AppColors.mineGridIconSurface, + ], + ), + shape: BoxShape.circle, + border: Border.all(color: AppColors.glassBorder), + boxShadow: [ + const BoxShadow( + color: AppColors.shadowSoft, + blurRadius: AppDimens.spaceSm, + offset: Offset(0, AppDimens.spaceXs), + ), + BoxShadow( + color: AppColors.brandRed.withValues(alpha: 0.12), + blurRadius: AppDimens.spaceSm, + ), + ], + ), + child: Icon( + item.icon, + color: AppColors.mineGridIconAccent, + size: AppDimens.mineGridIcon, + ), + ); + } + + Widget _label() { + return Text( + item.label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.mineGridLabel, + fontSize: AppDimens.mineGridLabel, + fontWeight: FontWeight.w500, + height: AppDimens.mineGridLabelLineHeight, + ), + ); + } +} + +class _MineVerticalDivider extends StatelessWidget { + const _MineVerticalDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: AppDimens.mineGridHairline, + color: AppColors.mineGridDivider, + ); + } +} + +class _MineHorizontalDivider extends StatelessWidget { + const _MineHorizontalDivider(); + + @override + Widget build(BuildContext context) { + return Container( + height: AppDimens.mineGridHairline, + color: AppColors.mineGridDivider, + ); + } +} diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index e2da934..27ffaef 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -5,11 +5,12 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; -import '../mine_types.dart'; -const double _vipRadius = 14; +const double _vipMaxTextScale = 1.15; +const double _vipGradientMiddleStop = 0.48; +const double _vipGlowRadius = 0.9; -/// 「我的」页金色 VIP 卡:品牌行 + 立即开通 + 2×2 权益宫格。 +/// 「我的」页高级 VIP 卡:品牌信息、会员徽章、开通按钮与横排权益。 class MineVipBanner extends StatelessWidget { const MineVipBanner({super.key, required this.onOpen}); @@ -20,104 +21,21 @@ class MineVipBanner extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Container( key: const Key('mine-vip'), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.vipCardStart, AppColors.vipCardEnd], - ), - borderRadius: BorderRadius.circular(_vipRadius), - ), + decoration: _decoration(), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(_vipRadius), + borderRadius: BorderRadius.circular(AppDimens.mineVipRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.cardPadding, - AppDimens.spaceSm + 2, - AppDimens.cardPadding, - AppDimens.spaceSm + 2, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: _vipMaxTextScale, + child: Stack( children: [ - Row( - children: [ - const Icon( - Icons.diamond_outlined, - color: AppColors.onGold, - size: 24, - ), - const SizedBox(width: AppDimens.spaceSm), - Expanded( - child: Text( - l10n.vipBrandName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onGold, - fontSize: 18, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: AppDimens.spaceSm), - _openButton(l10n), - ], - ), - Transform.translate( - offset: const Offset(0, -7), - child: Text( - l10n.mineVipSubtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: AppColors.onGold.withValues(alpha: 0.7), - fontSize: AppDimens.caption, - ), - ), - ), - const SizedBox(height: AppDimens.mineVipBenefitRowGap), - Container( - height: 1, - color: AppColors.onGold.withValues(alpha: 0.15), - ), - const SizedBox(height: AppDimens.mineVipBenefitRowGap), - Table( - defaultColumnWidth: const FlexColumnWidth(), - children: [ - TableRow( - children: [ - _benefit( - key: const Key('mine-vip-benefit-0'), - icon: Icons.play_circle_outline_rounded, - label: l10n.vipBenefitFree, - ), - _benefit( - key: const Key('mine-vip-benefit-1'), - icon: Icons.ads_click_rounded, - label: l10n.vipBenefitNoAds, - ), - ], - ), - TableRow( - children: [ - _benefit( - key: const Key('mine-vip-benefit-2'), - icon: Icons.hd_outlined, - label: l10n.vipBenefitHd, - ), - _benefit( - key: const Key('mine-vip-benefit-3'), - icon: Icons.download_for_offline_outlined, - label: l10n.vipBenefitFastCache, - ), - ], - ), - ], + const Positioned.fill(child: _VipAmbientGlow()), + Padding( + padding: const EdgeInsets.all(AppDimens.mineVipPadding), + child: _VipContent(l10n: l10n, onOpen: onOpen), ), ], ), @@ -127,147 +45,227 @@ class MineVipBanner extends StatelessWidget { ); } - Widget _openButton(AppLocalizations l10n) { - return TextButton( - key: const Key('mine-vip-open'), - onPressed: onOpen, - style: TextButton.styleFrom( - minimumSize: const Size(0, AppDimens.mineVipButtonHeight), - padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), - backgroundColor: AppColors.onGold, - foregroundColor: AppColors.vipGoldStart, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppDimens.mineVipButtonHeight / 2, - ), - ), - textStyle: const TextStyle( - fontSize: AppDimens.body, - fontWeight: FontWeight.w400, - ), + BoxDecoration _decoration() { + return BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.vipCardStart, + AppColors.vipCardMiddle, + AppColors.vipCardEnd, + ], + stops: [0, _vipGradientMiddleStop, 1], ), - child: Text(l10n.vipOpenNow), + 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, + offset: Offset(0, AppDimens.spaceSm), + ), + ], ); } +} - Widget _benefit({required Key key, required IconData icon, required String label}) { - return Padding( - key: key, - padding: const EdgeInsets.symmetric(vertical: AppDimens.mineVipBenefitRowGap), - child: Row( +class _VipContent extends StatelessWidget { + const _VipContent({required this.l10n, required this.onOpen}); + + final AppLocalizations l10n; + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _VipCopy(l10n: l10n)), + const SizedBox(width: AppDimens.spaceSm), + _VipAction(l10n: l10n, onOpen: onOpen), + ], + ), + const SizedBox(height: AppDimens.spaceSm), + const Divider(height: 1, color: AppColors.vipBenefitBorder), + const SizedBox(height: AppDimens.spaceSm), + _VipBenefits(l10n: l10n), + ], + ); + } +} + +class _VipCopy extends StatelessWidget { + const _VipCopy({required this.l10n}); + + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _VipPill(label: l10n.vipMembership), + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.vipBrandName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.vipCardText, + fontSize: AppDimens.mineVipTitleFont, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + l10n.mineVipSubtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.vipCardMuted, + fontSize: AppDimens.mineVipSubtitleFont, + height: 1.3, + ), + ), + ], + ); + } +} + +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; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: AppDimens.mineVipActionWidth, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Container( - width: AppDimens.mineVipBenefitBox, - height: AppDimens.mineVipBenefitBox, - decoration: BoxDecoration( - border: Border.all( - color: AppColors.onGold.withValues(alpha: 0.6), - width: 1.2, - ), - borderRadius: BorderRadius.circular( - AppDimens.mineVipBenefitBoxRadius, - ), - ), - child: Icon( - icon, - color: AppColors.onGold, - size: AppDimens.mineVipBenefitGlyph, - ), - ), - const SizedBox(width: AppDimens.spaceSm), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onGold, - fontSize: AppDimens.mineVipBenefitFont, - ), - ), - ), + const _VipEmblem(), + const SizedBox(height: AppDimens.spaceXs), + _VipOpenButton(label: l10n.vipOpenNow, onOpen: onOpen), ], ), ); } } -/// 快捷图标行:钱包 / 我的收藏 / 离线缓存 / 播放历史(设计稿的「奖励」暂不做)。 -class MineQuickActions extends StatelessWidget { - const MineQuickActions({ - super.key, - required this.onWallet, - required this.onFavorites, - required this.onDownloads, - required this.onHistory, - }); - - final VoidCallback onWallet; - final VoidCallback onFavorites; - final VoidCallback onDownloads; - final VoidCallback onHistory; +class _VipEmblem extends StatelessWidget { + const _VipEmblem(); @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Row( - children: [ - _action( - key: const Key('mine-wallet'), - icon: Icons.wallet_outlined, - label: l10n.wallet, - onTap: onWallet, + 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], ), - _action( - key: const Key('mine-favorites'), - icon: Icons.star_border_rounded, - label: l10n.myFavorites, - onTap: onFavorites, - ), - _action( - key: const Key('mine-downloads'), - icon: Icons.download_outlined, - label: l10n.offlineDownloads, - onTap: onDownloads, - ), - _action( - key: const Key('mine-history'), - icon: Icons.history_rounded, - label: l10n.watchHistory, - onTap: onHistory, - ), - ], + 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, + ), ); } +} - Widget _action({ - required Key key, - required IconData icon, - required String label, - required VoidCallback onTap, - }) { - return Expanded( - child: InkWell( - key: key, - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineQuickActionIcon), - const SizedBox(height: AppDimens.mineQuickActionGap), - Text( +class _VipOpenButton extends StatelessWidget { + const _VipOpenButton({required this.label, required this.onOpen}); + + final String label; + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + return SizedBox( + 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: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.mineQuickActionLabelFont, + fontSize: AppDimens.body, + fontWeight: FontWeight.w700, ), ), - ], + ), ), ), ), @@ -275,107 +273,117 @@ class MineQuickActions extends StatelessWidget { } } -/// 「更多服务」列表行:白图标 + 标题 + 可选值 + chevron,行间细分隔线。 -class MineServiceList extends StatelessWidget { - const MineServiceList({ - super.key, - required this.onOpen, - required this.onLanguage, - required this.languageName, - }); +class _VipBenefits extends StatelessWidget { + const _VipBenefits({required this.l10n}); - final ValueChanged onOpen; - final VoidCallback onLanguage; - final String languageName; + final AppLocalizations l10n; @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final rows = [ - _row( - key: const Key('mine-help'), - icon: Icons.headset_outlined, - label: l10n.contactSupport, - onTap: () => onOpen(MineDestination.help), - ), - _divider(), - _row( - key: const Key('mine-language'), - icon: Icons.language_rounded, - label: l10n.language, - value: languageName, - onTap: onLanguage, - ), - _divider(), - _row( - key: const Key('mine-settings'), - icon: Icons.settings_outlined, - label: l10n.settings, - onTap: () => onOpen(MineDestination.settings), - ), - _divider(), - _row( - key: const Key('mine-notices'), - icon: Icons.notifications_none_rounded, - label: l10n.notices, - onTap: () => onOpen(MineDestination.notices), - ), + final items = [ + (Icons.all_inclusive_rounded, l10n.vipBenefitFree), + (Icons.block_rounded, l10n.vipBenefitNoAds), + (Icons.hd_outlined, l10n.vipBenefitHd), + (Icons.download_for_offline_outlined, l10n.vipBenefitFastCache), ]; - return Column(key: const Key('mine-menu'), children: rows); - } - - Widget _divider() => const Divider( - height: AppDimens.mineGridDividerWidth, - thickness: AppDimens.mineGridDividerWidth, - color: AppColors.contentBorder, - ); - - Widget _row({ - required Key key, - required IconData icon, - required String label, - String? value, - required VoidCallback onTap, - }) { - return SizedBox( - height: AppDimens.mineServiceRowHeight, - child: InkWell( - key: key, - onTap: onTap, - child: Row( - children: [ - Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineServiceIcon), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.mineServiceTitleFont, - ), - ), + return Row( + key: const Key('mine-vip-benefits'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var index = 0; index < items.length; index++) + Expanded( + child: _VipBenefit( + itemKey: Key('mine-vip-benefit-$index'), + icon: items[index].$1, + label: items[index].$2, ), - if (value != null && value.isNotEmpty) ...[ - const SizedBox(width: AppDimens.spaceSm), - Text( - value, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.mineServiceValueFont, - ), + ), + ], + ); + } +} + +class _VipBenefit extends StatelessWidget { + const _VipBenefit({ + required this.itemKey, + required this.icon, + required this.label, + }); + + final Key itemKey; + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Column( + 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, + ), + ), + 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 _VipAmbientGlow extends StatelessWidget { + const _VipAmbientGlow(); + + @override + Widget build(BuildContext context) { + return Stack( + children: const [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(0.85, -0.8), + radius: _vipGlowRadius, + colors: [AppColors.vipCardGoldGlow, AppColors.transparent], + ), + ), + ), + ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(-0.9, 1), + radius: _vipGlowRadius, + colors: [AppColors.vipCardRedGlow, AppColors.transparent], ), - ], - const SizedBox(width: AppDimens.spaceXs), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 22, ), - ], + ), ), - ), + ], ); } } diff --git a/lib/pages/mine/widgets/mine_stat_icon.dart b/lib/pages/mine/widgets/mine_stat_icon.dart new file mode 100644 index 0000000..6c14110 --- /dev/null +++ b/lib/pages/mine/widgets/mine_stat_icon.dart @@ -0,0 +1,218 @@ +// Author: fruit.com.cn +// 绘制个人数据区统一的红色叠层质感图标。 + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; + +const double _designSize = 48; +const double _depth = 2; +const double _edgeWidth = 0.8; +const double _ringWidth = 1.1; +const double _groundBlur = 3; +const double _sparkleWaist = 0.28; +const double _screenTilt = -0.12; +const double _bookmarkTilt = 0.1; +const Offset _artCenter = Offset(24, 24); +const Rect _groundBounds = Rect.fromLTWH(8, 41, 32, 4); +const LinearGradient _faceGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.mineStatHighlight, + AppColors.brandRed, + AppColors.mineStatShade, + ], +); +const LinearGradient _backGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.brandRed, AppColors.mineStatDepth], +); + +/// 个人数据的三种图标语义。 +enum MineStatIconType { coins, history, favorites } + +/// 无位图依赖的品牌红图标,统一亮面、厚度与高光语言。 +class MineStatIcon extends StatelessWidget { + /// 根据数据类型绘制图标。 + const MineStatIcon({super.key, required this.type}); + + final MineStatIconType type; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: AppDimens.mineStatsIcon, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: RadialGradient( + colors: [AppColors.mineStatGlow, AppColors.transparent], + ), + ), + child: CustomPaint(painter: _MineStatPainter(type)), + ), + ); + } +} + +class _MineStatPainter extends CustomPainter { + const _MineStatPainter(this.type); + + final MineStatIconType type; + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(size.width / _designSize, size.height / _designSize); + canvas.drawOval( + _groundBounds, + Paint() + ..color = AppColors.mineStatGlow + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, _groundBlur), + ); + switch (type) { + case MineStatIconType.coins: + _coins(canvas); + case MineStatIconType.history: + _tilt(canvas, _screenTilt); + _screen(canvas); + case MineStatIconType.favorites: + _tilt(canvas, _bookmarkTilt); + _bookmark(canvas); + } + canvas.restore(); + } + + void _coins(Canvas canvas) { + const backBounds = Rect.fromLTWH(23, 6, 19, 19); + const frontBounds = Rect.fromLTWH(5, 15, 29, 29); + const innerCenter = Offset(19.5, 29.5); + const innerRadius = 11.3; + const stampRadius = 6.5; + const glintCenter = Offset(39, 33); + const double glintRadius = 3; + _surface(canvas, Path()..addOval(backBounds), backBounds, _backGradient); + _surface(canvas, Path()..addOval(frontBounds), frontBounds, _faceGradient); + canvas.drawCircle( + innerCenter, + innerRadius, + Paint() + ..color = AppColors.mineStatEdge + ..style = PaintingStyle.stroke + ..strokeWidth = _ringWidth, + ); + _sparkle(canvas, innerCenter, stampRadius); + _sparkle(canvas, glintCenter, glintRadius); + } + + void _screen(Canvas canvas) { + const backBounds = Rect.fromLTWH(12, 6, 29, 26); + const frontBounds = Rect.fromLTWH(6, 12, 32, 27); + const innerBounds = Rect.fromLTWH(10, 16, 24, 19); + const outerRadius = Radius.circular(6); + const innerRadius = Radius.circular(3); + const playPoints = [Offset(19, 20), Offset(19, 31), Offset(28, 25.5)]; + const reflectionStart = Offset(13, 14); + const reflectionEnd = Offset(26, 14); + final back = Path() + ..addRRect(RRect.fromRectAndRadius(backBounds, outerRadius)); + final front = Path() + ..addRRect(RRect.fromRectAndRadius(frontBounds, outerRadius)); + _surface(canvas, back, backBounds, _backGradient); + _surface(canvas, front, frontBounds, _faceGradient); + canvas.drawRRect( + RRect.fromRectAndRadius(innerBounds, innerRadius), + Paint()..shader = _backGradient.createShader(innerBounds), + ); + canvas.drawPath( + _polygon(playPoints), + Paint()..color = AppColors.mineStatGlint, + ); + _reflection(canvas, reflectionStart, reflectionEnd); + } + + void _bookmark(Canvas canvas) { + const bounds = Rect.fromLTWH(12, 7, 23, 35); + const radius = Radius.circular(4); + const cutout = [Offset(11, 44), Offset(23.5, 34), Offset(36, 44)]; + const rearOffset = Offset(5, -3); + const stampCenter = Offset(23.5, 21); + const double stampRadius = 6; + const reflectionStart = Offset(17, 10); + const reflectionEnd = Offset(28, 10); + final cover = Path()..addRRect(RRect.fromRectAndRadius(bounds, radius)); + final front = Path.combine( + PathOperation.difference, + cover, + _polygon(cutout), + ); + _surface( + canvas, + front.shift(rearOffset), + bounds.shift(rearOffset), + _backGradient, + ); + _surface(canvas, front, bounds, _faceGradient); + _sparkle(canvas, stampCenter, stampRadius); + _reflection(canvas, reflectionStart, reflectionEnd); + } + + void _surface(Canvas canvas, Path path, Rect bounds, Gradient gradient) { + canvas.save(); + canvas.translate(0, _depth); + canvas.drawPath(path, Paint()..color = AppColors.mineStatDepth); + canvas.restore(); + canvas.drawPath(path, Paint()..shader = gradient.createShader(bounds)); + canvas.drawPath( + path, + Paint() + ..color = AppColors.mineStatEdge + ..style = PaintingStyle.stroke + ..strokeJoin = StrokeJoin.round + ..strokeWidth = _edgeWidth, + ); + } + + void _sparkle(Canvas canvas, Offset center, double radius) { + final waist = radius * _sparkleWaist; + final points = [ + center + Offset(0, -radius), + center + Offset(waist, -waist), + center + Offset(radius, 0), + center + Offset(waist, waist), + center + Offset(0, radius), + center + Offset(-waist, waist), + center + Offset(-radius, 0), + center + Offset(-waist, -waist), + ]; + canvas.drawPath(_polygon(points), Paint()..color = AppColors.mineStatGlint); + } + + Path _polygon(List points) { + return Path()..addPolygon(points, true); + } + + void _reflection(Canvas canvas, Offset start, Offset end) { + canvas.drawLine( + start, + end, + Paint() + ..color = AppColors.mineStatEdge + ..strokeCap = StrokeCap.round + ..strokeWidth = _ringWidth, + ); + } + + void _tilt(Canvas canvas, double angle) { + canvas.translate(_artCenter.dx, _artCenter.dy); + canvas.rotate(angle); + canvas.translate(-_artCenter.dx, -_artCenter.dy); + } + + @override + bool shouldRepaint(covariant _MineStatPainter oldDelegate) { + return oldDelegate.type != type; + } +} diff --git a/lib/pages/mine/widgets/mine_stats_panel.dart b/lib/pages/mine/widgets/mine_stats_panel.dart index 7c2b344..93ee530 100644 --- a/lib/pages/mine/widgets/mine_stats_panel.dart +++ b/lib/pages/mine/widgets/mine_stats_panel.dart @@ -1,91 +1,230 @@ // Author: fruit.com.cn +// 展示可点击的金币、观看记录与收藏三列个人数据。 import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import '../../../core/data/services/auth_controller.dart'; +import '../../../core/data/services/favorite_controller.dart'; +import '../../../core/data/services/wallet_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +import 'mine_stat_icon.dart'; -class MineStatsPanel extends StatelessWidget { - const MineStatsPanel({ +const int _labelMaxLines = 2; +const double _dividerTop = 36; + +/// 三项个人数据入口的现有页面回调。 +class MineStatsActions { + /// 绑定钱包、观看记录与收藏页面。 + const MineStatsActions({ + required this.onWallet, + required this.onHistory, + required this.onFavorites, + }); + + final VoidCallback onWallet; + final VoidCallback onHistory; + final VoidCallback onFavorites; +} + +/// 将共享的账号、钱包和收藏状态绑定到个人数据区。 +class MineStatsSection extends StatelessWidget { + /// 观看记录由我的页逻辑提供,其余状态沿用全局 Provider。 + const MineStatsSection({ super.key, - required this.followingCount, required this.watchRecordCount, - required this.coinBalance, + required this.actions, }); - final int? followingCount; final int? watchRecordCount; + final MineStatsActions actions; + + @override + Widget build(BuildContext context) { + final signedIn = context.watch().isSignedIn; + final wallet = context.watch(); + final favorites = context.watch(); + return MineStatsPanel( + coinBalance: signedIn && wallet.error == null + ? wallet.snapshot?.wallet.coinBalance + : null, + watchRecordCount: watchRecordCount, + favoriteCount: favorites.loaded && favorites.error == null + ? favorites.total + : null, + actions: actions, + ); + } +} + +/// 头像与会员卡之间的个人数据,未知值与真实零值明确区分。 +class MineStatsPanel extends StatelessWidget { + /// 使用真实服务端总数与共享钱包余额构建数据区。 + const MineStatsPanel({ + super.key, + required this.coinBalance, + required this.watchRecordCount, + required this.favoriteCount, + required this.actions, + }); + final int? coinBalance; + final int? watchRecordCount; + final int? favoriteCount; + final MineStatsActions actions; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Container( + return Row( key: const Key('mine-stats'), - padding: const EdgeInsets.all(AppDimens.minePanelPadding), - decoration: BoxDecoration( - color: AppColors.minePanelSurface, - borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), - ), - child: Row( - children: [ - _stat( - key: const Key('mine-stat-following'), - value: followingCount, - label: l10n.followingCountLabel, - l10n: l10n, - ), - _stat( - key: const Key('mine-stat-watch-records'), - value: watchRecordCount, - label: l10n.watchRecordsCountLabel, - l10n: l10n, - ), - _stat( - key: const Key('mine-stat-coins'), - value: coinBalance, - label: l10n.coinBalance, - l10n: l10n, - ), - ], + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _tile( + type: MineStatIconType.coins, + value: coinBalance, + label: l10n.coinBalance, + onTap: actions.onWallet, + ), + _divider(), + _tile( + type: MineStatIconType.history, + value: watchRecordCount, + label: l10n.watchRecordsCountLabel, + onTap: actions.onHistory, + ), + _divider(), + _tile( + type: MineStatIconType.favorites, + value: favoriteCount, + label: l10n.myFavorites, + onTap: actions.onFavorites, + ), + ], + ); + } + + Widget _tile({ + required MineStatIconType type, + required int? value, + required String label, + required VoidCallback onTap, + }) { + return Expanded( + child: _MineStatTile( + type: type, + value: value, + label: label, + onTap: onTap, ), ); } - Widget _stat({ - required Key key, - required int? value, - required String label, - required AppLocalizations l10n, - }) { - return Expanded( - child: Column( - key: key, - children: [ - Text( - value?.toString() ?? l10n.dataUnavailable, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.mineStatsValueFont, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppDimens.spaceXs), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.mineStatsLabelFont, - ), - ), - ], + Widget _divider() { + return Padding( + padding: const EdgeInsets.only(top: _dividerTop), + child: Container( + width: AppDimens.mineStatsDividerWidth, + height: AppDimens.mineStatsDividerHeight, + color: AppColors.contentBorder, ), ); } } + +class _MineStatTile extends StatelessWidget { + const _MineStatTile({ + required this.type, + required this.value, + required this.label, + required this.onTap, + }); + + final MineStatIconType type; + final int? value; + final String label; + final VoidCallback onTap; + + String get _key => switch (type) { + MineStatIconType.coins => 'mine-stat-coins', + MineStatIconType.history => 'mine-stat-watch-records', + MineStatIconType.favorites => 'mine-stat-favorites', + }; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final formatted = value == null + ? l10n.dataUnavailable + : NumberFormat.decimalPattern(l10n.localeName).format(value); + return Semantics( + button: true, + label: label, + value: formatted, + onTap: onTap, + excludeSemantics: true, + child: Material( + color: AppColors.transparent, + child: InkWell( + key: ValueKey(_key), + onTap: onTap, + excludeFromSemantics: true, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppDimens.mineStatsMinHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceXs, + vertical: AppDimens.spaceSm, + ), + child: _content(formatted), + ), + ), + ), + ), + ); + } + + Widget _content(String formatted) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + MineStatIcon(key: ValueKey('$_key-icon'), type: type), + const SizedBox(height: AppDimens.spaceXs), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + formatted, + key: ValueKey('$_key-value'), + maxLines: 1, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.textPrimary, + fontSize: AppDimens.mineStatsValueFont, + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()], + height: AppDimens.mineStatsValueLineHeight, + ), + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + label, + maxLines: _labelMaxLines, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineStatsLabelFont, + height: AppDimens.mineStatsLabelLineHeight, + ), + ), + ], + ); + } +} diff --git a/test/mine/mine_layout_alignment_test.dart b/test/mine/mine_layout_alignment_test.dart new file mode 100644 index 0000000..9545c08 --- /dev/null +++ b/test/mine/mine_layout_alignment_test.dart @@ -0,0 +1,155 @@ +// Author: fruit.com.cn + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/mine/mine_types.dart'; +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; + +void main() { + testWidgets('should_keep_equal_visible_vip_padding_and_reduce_height', ( + tester, + ) async { + await _pumpLocalized( + tester, + const SizedBox(width: 342, child: MineVipBanner(onOpen: _noop)), + ); + + final cardRect = tester.getRect(find.byKey(const Key('mine-vip'))); + final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem'))); + final benefitsRect = tester.getRect( + find.byKey(const Key('mine-vip-benefits')), + ); + expect(cardRect.height, lessThan(_previousVipPlaceholderHeight)); + expect(cardRect.height, greaterThanOrEqualTo(_minimumVipHeight)); + expect( + emblemRect.top - cardRect.top, + closeTo(cardRect.bottom - benefitsRect.bottom, _layoutTolerance), + ); + }); + + for (final localeTag in ['en', 'id']) { + testWidgets( + 'should_center_visible_grid_content_when_locale_is_$localeTag', + (tester) async { + await _pumpLocalized( + tester, + const MineActionGrid( + primaryActions: MinePrimaryActions( + onWallet: _noop, + onFavorites: _noop, + onDownloads: _noop, + onHistory: _noop, + ), + serviceActions: MineServiceActions( + onOpen: _ignoreDestination, + onLanguage: _noop, + ), + ), + locale: Locale(localeTag), + ); + + for (final action in _actions) { + _expectVisibleContentCentered(tester, action); + } + }, + ); + } + + testWidgets('should_use_subtle_dividers_when_rendering_grid', (tester) async { + await _pumpLocalized( + tester, + const MineActionGrid( + primaryActions: MinePrimaryActions( + onWallet: _noop, + onFavorites: _noop, + onDownloads: _noop, + onHistory: _noop, + ), + serviceActions: MineServiceActions( + onOpen: _ignoreDestination, + onLanguage: _noop, + ), + ), + ); + + final dividers = find.descendant( + of: find.byKey(const Key('mine-menu')), + matching: find.byWidgetPredicate( + (widget) => + widget is Container && widget.color == AppColors.mineGridDivider, + ), + ); + expect(dividers, findsNWidgets(7)); + expect(AppColors.mineGridDivider, const Color(0x10FFFFFF)); + }); +} + +const _actions = [ + (Key('mine-wallet'), Key('mine-wallet-icon-surface')), + (Key('mine-favorites'), Key('mine-favorites-icon-surface')), + (Key('mine-downloads'), Key('mine-downloads-icon-surface')), + (Key('mine-history'), Key('mine-history-icon-surface')), + (Key('mine-help'), Key('mine-help-icon-surface')), + (Key('mine-language'), Key('mine-language-icon-surface')), + (Key('mine-settings'), Key('mine-settings-icon-surface')), + (Key('mine-notices'), Key('mine-notices-icon-surface')), +]; + +void _expectVisibleContentCentered(WidgetTester tester, (Key, Key) action) { + final cellRect = tester.getRect(find.byKey(action.$1)); + final iconRect = tester.getRect(find.byKey(action.$2)); + final labelRect = tester.getRect( + find.descendant(of: find.byKey(action.$1), matching: find.byType(Text)), + ); + final visibleRect = Rect.fromLTRB( + math.min(iconRect.left, labelRect.left), + iconRect.top, + math.max(iconRect.right, labelRect.right), + labelRect.bottom, + ); + expect(visibleRect.center.dx, closeTo(cellRect.center.dx, _layoutTolerance)); + expect(visibleRect.center.dy, closeTo(cellRect.center.dy, _layoutTolerance)); +} + +Future _pumpLocalized( + WidgetTester tester, + Widget child, { + Locale locale = const Locale('en'), +}) async { + tester.view.physicalSize = const Size(390, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppDimens.pageHorizontal), + child: child, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void _noop() {} + +void _ignoreDestination(MineDestination _) {} diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 0bbafee..6bcca8e 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -16,7 +16,7 @@ import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/mine/about_page.dart'; import 'package:fruitshorts/pages/mine/mine_types.dart'; -import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; +import 'package:fruitshorts/pages/mine/widgets/mine_action_grid.dart'; import 'package:fruitshorts/pages/store/vip_page.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -78,11 +78,11 @@ void main() { ]); expect(background.stops, const [0, 0.14, 0.3]); - // 快捷图标行:钱包 / 收藏 / 离线缓存 / 观看历史。 + // 统一操作宫格第一排:钱包 / 收藏 / 离线缓存 / 观看历史。 const quickActions = [ - (Key('mine-wallet'), Icons.wallet_outlined), - (Key('mine-favorites'), Icons.star_border_rounded), - (Key('mine-downloads'), Icons.download_outlined), + (Key('mine-wallet'), Icons.account_balance_wallet_outlined), + (Key('mine-favorites'), Icons.star_outline_rounded), + (Key('mine-downloads'), Icons.download_for_offline_outlined), (Key('mine-history'), Icons.history_rounded), ]; for (final action in quickActions) { @@ -92,44 +92,124 @@ void main() { matching: find.byIcon(action.$2), ), ); - expect(icon.color, AppColors.textPrimary); - expect(icon.size, AppDimens.mineQuickActionIcon); + expect(icon.color, AppColors.mineGridIconAccent); + expect(icon.size, AppDimens.mineGridIcon); + expect( + find.descendant( + of: find.byKey(const Key('mine-menu')), + matching: find.byKey(action.$1), + ), + findsOneWidget, + ); } - // 「更多服务」列表:联系客服 / 语言 / 设置 / 通知。 + // 「更多服务」宫格:联系客服 / 语言 / 设置 / 通知。 const services = [ - (Key('mine-help'), Icons.headset_outlined), - (Key('mine-language'), Icons.language_rounded), - (Key('mine-settings'), Icons.settings_outlined), - (Key('mine-notices'), Icons.notifications_none_rounded), + ( + Key('mine-help'), + Key('mine-help-icon-surface'), + Icons.support_agent_outlined, + ), + ( + Key('mine-language'), + Key('mine-language-icon-surface'), + Icons.language_rounded, + ), + ( + Key('mine-settings'), + Key('mine-settings-icon-surface'), + Icons.settings_outlined, + ), + ( + Key('mine-notices'), + Key('mine-notices-icon-surface'), + Icons.notifications_none_rounded, + ), ]; for (final service in services) { expect(find.byKey(service.$1), findsOneWidget); final icon = tester.widget( find.descendant( of: find.byKey(service.$1), - matching: find.byIcon(service.$2), + matching: find.byIcon(service.$3), ), ); - expect(icon.color, AppColors.textPrimary); - expect(icon.size, AppDimens.mineServiceIcon); + expect(icon.color, AppColors.mineGridIconAccent); + expect(icon.size, AppDimens.mineGridIcon); + expect( + tester.getSize(find.byKey(service.$2)), + const Size.square(AppDimens.mineGridIconSurface), + ); } expect(find.byKey(const Key('mine-menu')), findsOneWidget); expect(tester.getTopLeft(find.byKey(const Key('mine-menu'))).dx, 14); + expect( + tester.getSize(find.byKey(const Key('mine-menu'))).height, + AppDimens.mineGridHeight, + ); + final serviceLefts = services + .map((service) => tester.getTopLeft(find.byKey(service.$1)).dx) + .toList(); + expect(serviceLefts, [...serviceLefts]..sort()); + expect(serviceLefts.toSet(), hasLength(services.length)); + expect( + tester.getTopLeft(find.byKey(const Key('mine-help'))).dy, + greaterThan(tester.getTopLeft(find.byKey(const Key('mine-wallet'))).dy), + ); + final menu = tester.widget( + find.byKey(const Key('mine-menu')), + ); + final menuDecoration = menu.decoration as BoxDecoration; + expect(menuDecoration.color, AppColors.mineGridSurface); + expect( + menuDecoration.borderRadius, + BorderRadius.circular(AppDimens.mineGridRadius), + ); final vip = tester.widget(find.byKey(const Key('mine-vip'))); final vipDecoration = vip.decoration! as BoxDecoration; expect((vipDecoration.gradient! as LinearGradient).colors, const [ AppColors.vipCardStart, + AppColors.vipCardMiddle, AppColors.vipCardEnd, ]); - expect(vipDecoration.boxShadow, isNull); + expect(vipDecoration.border, isNotNull); + expect(vipDecoration.boxShadow, isNotEmpty); + + // 卡片高度由内容撑开,上下内边距必须一致(边框各占 1pt)。 + 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( + find.byKey(const Key('mine-vip-benefits')), + ); + final topInset = emblemRect.top - vipRect.top; + final bottomInset = vipRect.bottom - benefitsRect.bottom; + expect(topInset, closeTo(bottomInset, 0.01)); + expect(topInset, 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), + ); + const benefitKeys = [ + Key('mine-vip-benefit-0'), + Key('mine-vip-benefit-1'), + Key('mine-vip-benefit-2'), + Key('mine-vip-benefit-3'), + ]; + 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), + ); final order = [ tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy, tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, tester.getTopLeft(find.byKey(const Key('mine-wallet'))).dy, - tester.getTopLeft(find.byKey(const Key('mine-help'))).dy, ]; expect(order, [...order]..sort()); expect(tester.takeException(), isNull); @@ -181,6 +261,7 @@ void main() { expect(find.byType(MineAboutPage), findsOneWidget); expect(find.byKey(const Key('mine-about-page')), findsOneWidget); expect(find.text('Version 1.0.0'), findsOneWidget); + expect(find.text('Your mini business'), findsOneWidget); expect( find.text( AppLocalizations.of( @@ -245,16 +326,17 @@ void main() { padding: const EdgeInsets.all(AppDimens.pageHorizontal), child: Column( children: [ - MineQuickActions( - onWallet: () => opened.add(MineDestination.wallet), - onFavorites: () => favoritesOpened++, - onDownloads: () => opened.add(MineDestination.downloads), - onHistory: () => opened.add(MineDestination.history), - ), - MineServiceList( - onOpen: opened.add, - onLanguage: () => languageOpened++, - languageName: 'English', + MineActionGrid( + primaryActions: MinePrimaryActions( + onWallet: () => opened.add(MineDestination.wallet), + onFavorites: () => favoritesOpened++, + onDownloads: () => opened.add(MineDestination.downloads), + onHistory: () => opened.add(MineDestination.history), + ), + serviceActions: MineServiceActions( + onOpen: opened.add, + onLanguage: () => languageOpened++, + ), ), ], ), diff --git a/test/mine/mine_stats_data_test.dart b/test/mine/mine_stats_data_test.dart new file mode 100644 index 0000000..393271c --- /dev/null +++ b/test/mine/mine_stats_data_test.dart @@ -0,0 +1,324 @@ +// Author: fruit.com.cn +// 验证我的页个人数据的真实总数、未知状态、导航与刷新。 + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; +import '../support/widget_capture.dart'; + +const _coins = Key('mine-stat-coins'); +const _history = Key('mine-stat-watch-records'); +const _favorites = Key('mine-stat-favorites'); +const _stats = Key('mine-stats'); +const _screenSize = Size(390, 844); +const _initialBalance = 320; +const _historyTotal = 57; +const _favoritesTotal = 23; +const _updatedBalance = 270; +const _updatedHistoryTotal = 58; +const _updatedFavoritesTotal = 24; +const _unavailable = '—'; +const _captureEnabled = bool.fromEnvironment('MINE_STATS_CAPTURE'); +const _captureKey = Key('mine-stats-capture'); +const _capturePath = '/tmp/fruitshorts-mine-personal-stats.png'; + +void main() { + testWidgets('should_place_three_live_totals_above_vip_when_mine_opens', ( + tester, + ) async { + if (_captureEnabled) await loadCaptureFonts(tester); + await _pumpFixture(tester, _StatsFixture()); + + expect(find.byKey(_stats), findsOneWidget); + expect(_value(tester, _coins), '320'); + expect(_value(tester, _history), '57'); + expect(_value(tester, _favorites), '23'); + final order = [ + tester.getBottomLeft(find.byKey(const Key('mine-user-row'))).dy, + tester.getTopLeft(find.byKey(_stats)).dy, + tester.getBottomLeft(find.byKey(_stats)).dy, + tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, + ]; + expect(order, orderedEquals([...order]..sort())); + final tops = [ + _coins, + _history, + _favorites, + ].map((key) => tester.getTopLeft(find.byKey(key)).dy); + expect(tops.toSet(), hasLength(1)); + expect(tester.takeException(), isNull); + if (_captureEnabled) { + await saveWidgetCapture(tester, _captureKey, _capturePath); + } + }); + + testWidgets('should_keep_device_counts_but_hide_balance_when_guest', ( + tester, + ) async { + final fixture = _StatsFixture(); + await _pumpFixture(tester, fixture, signedIn: false); + + expect(_value(tester, _coins), _unavailable); + expect(_value(tester, _history), '57'); + expect(_value(tester, _favorites), '23'); + expect(fixture.commerce.loads, 0); + await tester.tap(find.byKey(_coins)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('wallet-page')), findsNothing); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(find.byType(AuthPage), findsOneWidget); + expect(find.byKey(const Key('wallet-page')), findsNothing); + }); + + testWidgets('should_use_placeholders_until_actual_zero_totals_load', ( + tester, + ) async { + final gate = Completer(); + final fixture = _StatsFixture() + ..history.gate = gate + ..favorites.gate = gate + ..commerce.gate = gate + ..history.total = 0 + ..favorites.total = 0 + ..commerce.balance = 0; + await _pumpFixture(tester, fixture); + + for (final key in [_coins, _history, _favorites]) { + expect(_value(tester, key), _unavailable); + } + gate.complete(); + await tester.pumpAndSettle(); + for (final key in [_coins, _history, _favorites]) { + expect(_value(tester, key), '0'); + } + }); + + testWidgets('should_not_display_fake_zero_when_data_requests_fail', ( + tester, + ) async { + final fixture = _StatsFixture() + ..history.failRecent = true + ..favorites.failPage = true + ..commerce.failLoad = true; + await _pumpFixture(tester, fixture); + + for (final key in [_coins, _history, _favorites]) { + expect(_value(tester, key), _unavailable); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_existing_destinations_when_statistics_are_tapped', ( + tester, + ) async { + await _pumpFixture(tester, _StatsFixture()); + + const destinations = [ + (_coins, Key('wallet-page')), + (_history, Key('watch-records-page')), + (_favorites, Key('favorites-page')), + ]; + for (final destination in destinations) { + await tester.tap(find.byKey(destination.$1)); + await tester.pumpAndSettle(); + expect(find.byKey(destination.$2), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('should_refresh_totals_when_mine_becomes_visible_again', ( + tester, + ) async { + final fixture = _StatsFixture(); + await _pumpFixture(tester, fixture); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + fixture.history.total = _updatedHistoryTotal; + fixture.favorites.total = _updatedFavoritesTotal; + fixture.commerce.balance = _updatedBalance; + + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + + expect(_value(tester, _coins), '270'); + expect(_value(tester, _history), '58'); + expect(_value(tester, _favorites), '24'); + }); + + testWidgets('should_refresh_favorites_when_returning_from_favorites_page', ( + tester, + ) async { + final fixture = _StatsFixture(); + await _pumpFixture(tester, fixture); + await tester.tap(find.byKey(_favorites)); + await tester.pumpAndSettle(); + fixture.favorites.total = _updatedFavoritesTotal; + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(_value(tester, _favorites), '24'); + }); + + testWidgets('should_hide_previous_balance_immediately_when_signed_out', ( + tester, + ) async { + await _pumpFixture(tester, _StatsFixture()); + final auth = tester.element(find.byKey(_stats)).read(); + + await auth.clear(); + await tester.pumpAndSettle(); + + expect(_value(tester, _coins), _unavailable); + expect(find.byKey(const Key('mine-login')), findsOneWidget); + }); +} + +String? _value(WidgetTester tester, Key key) { + final valueKey = Key('${(key as ValueKey).value}-value'); + return tester.widget(find.byKey(valueKey)).data; +} + +Future _pumpFixture( + WidgetTester tester, + _StatsFixture fixture, { + bool signedIn = true, +}) async { + tester.view.physicalSize = _screenSize; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferencesAsyncPlatform.instance = signedIn + ? InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}', + }) + : InMemorySharedPreferencesAsync.empty(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + RepaintBoundary( + key: _captureKey, + child: LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: fixture.history, + favoriteRepository: fixture.favorites, + commerceRepository: fixture.commerce, + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); +} + +class _StatsFixture { + final history = _StatsHistory(); + final favorites = _StatsFavorites(); + final commerce = _StatsCommerce(); +} + +class _StatsHistory extends InMemoryWatchHistoryRepository { + int total = _historyTotal; + bool failRecent = false; + Completer? gate; + + @override + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + if (gate != null) await gate!.future; + if (failRecent) throw StateError('history unavailable'); + return PageResult( + total: total, + list: total == 0 + ? const [] + : const [ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-1', + position: Duration.zero, + duration: Duration(minutes: 1), + ), + ], + ); + } +} + +class _StatsFavorites extends InMemoryFavoriteRepository { + int total = _favoritesTotal; + Completer? gate; + + @override + Future> page({int pageIndex = 1, int pageSize = 20}) async { + if (gate != null) await gate!.future; + if (failPage) throw StateError('favorites unavailable'); + return PageResult( + total: total, + list: total == 0 ? const [] : demoDramas.take(1).toList(), + ); + } +} + +class _StatsCommerce implements CommerceRepository { + int balance = _initialBalance; + int loads = 0; + bool failLoad = false; + Completer? gate; + + @override + Future fetchWallet() async { + loads += 1; + if (gate != null) await gate!.future; + if (failLoad) throw StateError('wallet unavailable'); + return WalletSnapshot( + wallet: CoinWallet( + coinBalance: balance, + totalRechargedCoin: balance, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ); + } + + @override + Future unlock(String episodeId) async { + return UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 0, + coinBalance: balance, + ); + } +} diff --git a/test/mine/mine_stats_panel_test.dart b/test/mine/mine_stats_panel_test.dart new file mode 100644 index 0000000..59b6178 --- /dev/null +++ b/test/mine/mine_stats_panel_test.dart @@ -0,0 +1,168 @@ +// Author: fruit.com.cn +// 验证个人数据图标、无障碍入口与多语言窄屏布局。 + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/mine/widgets/mine_stat_icon.dart'; +import 'package:fruitshorts/pages/mine/widgets/mine_stats_panel.dart'; + +const _coinsKey = Key('mine-stat-coins'); +const _historyKey = Key('mine-stat-watch-records'); +const _favoritesKey = Key('mine-stat-favorites'); +const _locales = [Locale('zh'), Locale('en'), Locale('id')]; +const _widths = [320.0, 390.0]; +const _scales = [1.0, 2.0]; +const _screenHeight = 600.0; +const _largeBalance = 123456789; +const _historyTotal = 57; +const _favoritesTotal = 23; + +void main() { + _responsiveTests(); + _interactionTests(); +} + +void _responsiveTests() { + for (final locale in _locales) { + for (final width in _widths) { + for (final scale in _scales) { + testWidgets( + 'should_fit_three_columns_when_${locale.languageCode}_${width}_scale_$scale', + (tester) async { + await _pumpPanel( + tester, + locale: locale, + width: width, + scale: scale, + ); + + final cells = [_coinsKey, _historyKey, _favoritesKey]; + final positions = cells + .map((key) => tester.getTopLeft(find.byKey(key))) + .toList(); + expect(positions.map((point) => point.dy).toSet(), hasLength(1)); + expect(positions.map((point) => point.dx).toSet(), hasLength(3)); + for (final key in cells) { + final size = tester.getSize(find.byKey(key)); + expect(size.width, greaterThanOrEqualTo(AppDimens.tapTarget)); + expect(size.height, greaterThanOrEqualTo(AppDimens.tapTarget)); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + } +} + +void _interactionTests() { + testWidgets( + 'should_render_distinct_vector_icons_when_statistics_are_visible', + (tester) async { + await _pumpPanel(tester); + + final icons = tester.widgetList(find.byType(MineStatIcon)); + expect(icons.map((icon) => icon.type), MineStatIconType.values); + for (final icon in icons) { + expect( + tester.getSize(find.byKey(icon.key!)), + const Size.square(AppDimens.mineStatsIcon), + ); + } + }, + ); + + testWidgets('should_forward_each_action_once_when_statistics_are_tapped', ( + tester, + ) async { + final opened = []; + await _pumpPanel( + tester, + actions: MineStatsActions( + onWallet: () => opened.add(MineStatIconType.coins), + onHistory: () => opened.add(MineStatIconType.history), + onFavorites: () => opened.add(MineStatIconType.favorites), + ), + ); + + for (final key in [_coinsKey, _historyKey, _favoritesKey]) { + await tester.tap(find.byKey(key)); + await tester.pump(); + } + expect(opened, MineStatIconType.values); + }); + + testWidgets( + 'should_announce_label_value_and_action_when_using_screen_reader', + (tester) async { + await _pumpPanel(tester); + final semantics = tester.ensureSemantics(); + final coinSemantics = find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Coin balance', + ); + + try { + expect( + tester.getSemantics(coinSemantics), + matchesSemantics( + label: 'Coin balance', + value: '123,456,789', + isButton: true, + hasTapAction: true, + ), + ); + } finally { + semantics.dispose(); + } + }, + ); +} + +Future _pumpPanel( + WidgetTester tester, { + Locale locale = const Locale('en'), + double width = 390, + double scale = 1, + MineStatsActions? actions, +}) async { + tester.view.physicalSize = Size(width, _screenHeight); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(AppDimens.pageHorizontal), + child: MineStatsPanel( + coinBalance: _largeBalance, + watchRecordCount: _historyTotal, + favoriteCount: _favoritesTotal, + actions: + actions ?? + MineStatsActions( + onWallet: () {}, + onHistory: () {}, + onFavorites: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/support/widget_capture.dart b/test/support/widget_capture.dart new file mode 100644 index 0000000..d1c2922 --- /dev/null +++ b/test/support/widget_capture.dart @@ -0,0 +1,60 @@ +// Author: fruit.com.cn +// 为界面视觉复核加载项目字体并导出测试渲染图。 + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const double _capturePixelRatio = 3; + +/// 为可选截图使用实际字体,不影响普通测试执行。 +Future loadCaptureFonts(WidgetTester tester) async { + await tester.runAsync(() async { + final fonts = { + 'Outfit': [ + 'Outfit-Regular.ttf', + 'Outfit-SemiBold.ttf', + 'Outfit-Bold.ttf', + ], + 'PlusJakartaSans': [ + 'PlusJakartaSans-Regular.ttf', + 'PlusJakartaSans-Medium.ttf', + 'PlusJakartaSans-SemiBold.ttf', + 'PlusJakartaSans-Bold.ttf', + ], + // 测试默认字体仅作占位,截图使用项目正文字体替换。 + 'Ahem': ['PlusJakartaSans-Regular.ttf'], + }; + for (final family in fonts.entries) { + final loader = FontLoader(family.key); + for (final asset in family.value) { + loader.addFont(rootBundle.load('assets/fonts/$asset')); + } + await loader.load(); + } + final icons = FontLoader('MaterialIcons'); + icons.addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await icons.load(); + }); +} + +/// 导出指定边界的渲染结果,图片为测试产物而非界面资源。 +Future saveWidgetCapture( + WidgetTester tester, + Key boundaryKey, + String path, +) async { + final boundary = tester.renderObject( + find.byKey(boundaryKey), + ); + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: _capturePixelRatio); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + if (bytes == null) throw StateError('Screenshot encoding failed'); + await File(path).writeAsBytes(bytes.buffer.asUint8List()); + image.dispose(); + }); +}