diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index e2a00ef..1b2c8d8 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1254,3 +1254,9 @@ $ flutter test - 继续观看改为无边框、无底色的纯文字按钮,默认 `#B0B0B0`、12/w500,按压态使用品牌色。 - 保持 28 高点击热区和右侧零间距;未执行测试或 analyze。 + +## 2026-10-22 我的页渐变与图标网格 + +- 页面背景改为暖黑三段纵向渐变,用户区与 VIP 卡布局保持不变。 +- 行式入口替换为 4 列图标网格,7 个原入口按顺序保留,第 8 格为空占位。 +- 网格使用 14 边距、半透明深灰底、圆角 12 和仅内部 1px 分隔线;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 4536f27..db57bdd 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -84,6 +84,13 @@ abstract final class AppColors { /// 「我的」页卡片与描边。 static const mineCard = Color(0xFF16161A); static const mineOutline = Color(0xFF4A4A4A); + static const mineGradientTop = Color(0xFF1A0F0A); + static const mineGradientMiddle = Color(0xFF0D0908); + static const mineGradientBottom = Color(0xFF000000); + static const mineGridSurface = Color(0x99101010); + static const mineGridDivider = Color(0xFF2A2A2A); + static const mineGridIconSurface = Color(0xFF1C1C1E); + static const mineGridLabel = Color(0xFFE6E6EB); /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 5ba9597..99641fb 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -179,11 +179,16 @@ abstract final class AppDimens { /// 「我的」页中性视觉。 static const double mineCardRadius = 16; - static const double mineRowHeight = 56; static const double minePageTitle = 17; static const double mineSectionTitle = 15; static const double mineAvatar = 56; static const double mineUserName = 20; static const double mineUid = 13; - static const double mineMenuIcon = 22; + static const double mineGridHorizontal = 14; + static const double mineGridRadius = 12; + static const double mineGridCellHeight = 96; + static const double mineGridIconSurface = 44; + static const double mineGridIcon = 22; + static const double mineGridLabel = 12; + static const double mineGridDividerWidth = 1; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index fea424f..43ce56b 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -80,25 +81,39 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final content = ColoredBox( + final content = DecoratedBox( key: const Key('mine-page-surface'), - color: AppColors.appBackground, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ], + stops: [0, 0.48, 1], + ), + ), child: SafeArea( bottom: false, child: Column( children: [ _topBar(l10n), - Expanded(child: _body(context, l10n)), + Expanded(child: _body(context)), if (!embedded) _bottomNav(), ], ), ), ); - return Theme( - data: AppTheme.dark(), - child: embedded - ? content - : Scaffold(backgroundColor: AppColors.appBackground, body: content), + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Theme( + data: AppTheme.dark(), + child: embedded + ? content + : Scaffold(backgroundColor: AppColors.appBackground, body: content), + ), ); } @@ -125,24 +140,34 @@ class _MyPageState extends State { ); } - Widget _body(BuildContext context, AppLocalizations l10n) { - final locale = context.watch(); + Widget _body(BuildContext context) { return ListView( key: const Key('mine-page-scroll'), - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - 0, - AppDimens.pageHorizontal, - AppDimens.spaceXl, - ), + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), children: [ - MineHeader(onLogin: _openLogin, onEdit: _openEditActions), - MineVipBanner(onOpen: () => _openDestination(MineDestination.vip)), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: MineHeader(onLogin: _openLogin, onEdit: _openEditActions), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: MineVipBanner( + onOpen: () => _openDestination(MineDestination.vip), + ), + ), const SizedBox(height: AppDimens.spaceMd), - MineMenuList( - onOpen: _openDestination, - onLanguage: _openLanguage, - languageLabel: _languageLabel(l10n, locale.locale), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: MineMenuList( + onOpen: _openDestination, + onLanguage: _openLanguage, + ), ), ], ); @@ -252,15 +277,6 @@ class _MyPageState extends State { _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); } - String _languageLabel(AppLocalizations l10n, Locale? locale) { - return switch (locale?.languageCode) { - 'zh' => l10n.simplifiedChinese, - 'en' => l10n.english, - 'id' => l10n.bahasaIndonesia, - _ => l10n.language, - }; - } - Future _openLanguage() async { final selected = await showModalBottomSheet( context: context, diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index ed5be25..6146127 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -108,18 +108,16 @@ class MineVipBanner extends StatelessWidget { } } -/// 「我的」页单列功能菜单。 +/// 「我的」页 4 列功能入口。 class MineMenuList extends StatelessWidget { const MineMenuList({ super.key, required this.onOpen, required this.onLanguage, - required this.languageLabel, }); final ValueChanged onOpen; final VoidCallback onLanguage; - final String languageLabel; @override Widget build(BuildContext context) { @@ -127,58 +125,85 @@ class MineMenuList extends StatelessWidget { return Container( key: const Key('mine-menu'), decoration: BoxDecoration( - color: AppColors.mineCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), + color: AppColors.mineGridSurface, + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), clipBehavior: Clip.antiAlias, - child: Column( + child: Table( + key: const Key('mine-menu-grid'), + defaultColumnWidth: const FlexColumnWidth(), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + border: const TableBorder( + horizontalInside: BorderSide( + color: AppColors.mineGridDivider, + width: AppDimens.mineGridDividerWidth, + ), + verticalInside: BorderSide( + color: AppColors.mineGridDivider, + width: AppDimens.mineGridDividerWidth, + ), + ), children: [ - _row( - key: const Key('mine-wallet'), - icon: Icons.account_balance_wallet_outlined, - title: l10n.wallet, - destination: MineDestination.wallet, + TableRow( + children: [ + _cell( + index: 0, + actionKey: const Key('mine-wallet'), + icon: Icons.account_balance_wallet_outlined, + label: l10n.wallet, + onTap: () => onOpen(MineDestination.wallet), + ), + _cell( + index: 1, + actionKey: const Key('mine-downloads'), + icon: Icons.download_outlined, + label: l10n.offlineDownloads, + onTap: () => onOpen(MineDestination.downloads), + ), + _cell( + index: 2, + actionKey: const Key('mine-history'), + icon: Icons.history_rounded, + label: l10n.watchHistory, + onTap: () => onOpen(MineDestination.history), + ), + _cell( + index: 3, + actionKey: const Key('mine-watch-preferences'), + icon: Icons.tune_rounded, + label: l10n.watchPreferences, + onTap: () => onOpen(MineDestination.preferences), + ), + ], ), - _divider(), - _row( - key: const Key('mine-downloads'), - icon: Icons.download_outlined, - title: l10n.offlineDownloads, - destination: MineDestination.downloads, - ), - _divider(), - _row( - key: const Key('mine-history'), - icon: Icons.history_rounded, - title: l10n.watchHistory, - destination: MineDestination.history, - ), - _divider(), - _row( - key: const Key('mine-watch-preferences'), - icon: Icons.tune_rounded, - title: l10n.watchPreferences, - destination: MineDestination.preferences, - ), - _divider(), - _row( - key: const Key('mine-help'), - icon: Icons.support_agent_rounded, - title: l10n.contactSupport, - destination: MineDestination.help, - ), - _divider(), - _languageRow(l10n), - _divider(), - _row( - key: const Key('mine-settings'), - icon: Icons.settings_outlined, - title: l10n.settings, - destination: MineDestination.settings, + TableRow( + children: [ + _cell( + index: 4, + actionKey: const Key('mine-help'), + icon: Icons.support_agent_rounded, + label: l10n.contactSupport, + onTap: () => onOpen(MineDestination.help), + ), + _cell( + index: 5, + actionKey: const Key('mine-language'), + icon: Icons.language_rounded, + label: l10n.language, + onTap: onLanguage, + ), + _cell( + index: 6, + actionKey: const Key('mine-settings'), + icon: Icons.settings_outlined, + label: l10n.settings, + onTap: () => onOpen(MineDestination.settings), + ), + _cell(index: 7), + ], ), ], ), @@ -186,84 +211,58 @@ class MineMenuList extends StatelessWidget { ); } - Widget _languageRow(AppLocalizations l10n) { - return ListTile( - key: const Key('mine-language'), - minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), - leading: const Icon( - Icons.language_rounded, - color: AppColors.textPrimary, - size: AppDimens.mineMenuIcon, - ), - title: Text( - l10n.language, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w500, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - languageLabel, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(width: AppDimens.spaceXs), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 18, - ), - ], - ), - onTap: onLanguage, - ); - } - - Widget _row({ - required Key key, - required IconData icon, - required String title, - required MineDestination destination, + Widget _cell({ + required int index, + Key? actionKey, + IconData? icon, + String? label, + VoidCallback? onTap, }) { - return ListTile( - key: key, - minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), - leading: Icon( - icon, - color: AppColors.textPrimary, - size: AppDimens.mineMenuIcon, - ), - title: Text( - title, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w500, - ), - ), - trailing: const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 18, - ), - onTap: () => onOpen(destination), - ); - } - - Widget _divider() { - return const Divider( - height: 1, - indent: AppDimens.spaceLg, - endIndent: AppDimens.spaceLg, - color: AppColors.contentSurface, + return SizedBox( + key: Key('mine-menu-cell-$index'), + height: AppDimens.mineGridCellHeight, + child: actionKey == null + ? const SizedBox.shrink() + : InkWell( + key: actionKey, + onTap: onTap, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: AppDimens.mineGridIconSurface, + height: AppDimens.mineGridIconSurface, + decoration: const BoxDecoration( + color: AppColors.mineGridIconSurface, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: AppColors.primary, + size: AppDimens.mineGridIcon, + ), + ), + const SizedBox(height: AppDimens.spaceXs + 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + label ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.mineGridLabel, + fontSize: AppDimens.mineGridLabel, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + ), + ], + ), + ), + ), ); } } diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 5f63599..b017ed0 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -25,7 +25,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_visitor_user_vip_and_seven_menu_rows', ( + testWidgets('should_render_visitor_user_vip_and_seven_grid_entries', ( tester, ) async { await _pumpApp(tester, signedIn: false); @@ -50,6 +50,18 @@ void main() { expect(title.style?.fontSize, AppDimens.minePageTitle); expect(title.style?.fontWeight, FontWeight.w700); expect(find.byKey(const Key('mine-mini-player')), findsNothing); + final surface = tester.widget( + find.byKey(const Key('mine-page-surface')), + ); + final surfaceDecoration = surface.decoration as BoxDecoration; + final background = surfaceDecoration.gradient! as LinearGradient; + expect(background.begin, Alignment.topCenter); + expect(background.end, Alignment.bottomCenter); + expect(background.colors, const [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ]); for (final oldShortcut in const [ Key('mine-watchlist'), Key('mine-history-shortcut'), @@ -58,13 +70,7 @@ void main() { expect(find.byKey(oldShortcut), findsNothing); } - final menuRows = tester.widgetList( - find.descendant( - of: find.byKey(const Key('mine-menu')), - matching: find.byType(ListTile), - ), - ); - expect(menuRows.map((row) => row.key), const [ + const entryKeys = [ Key('mine-wallet'), Key('mine-downloads'), Key('mine-history'), @@ -72,32 +78,59 @@ void main() { Key('mine-help'), Key('mine-language'), Key('mine-settings'), - ]); - expect(menuRows, hasLength(7)); + ]; + expect(entryKeys, hasLength(7)); + for (final entryKey in entryKeys) { + expect(find.byKey(entryKey), findsOneWidget); + } + for (var index = 0; index < 8; index++) { + expect(find.byKey(Key('mine-menu-cell-$index')), findsOneWidget); + } expect( - menuRows.every((row) => row.minTileHeight == AppDimens.mineRowHeight), - isTrue, + find.descendant( + of: find.byKey(const Key('mine-menu-cell-7')), + matching: find.byType(Icon), + ), + findsNothing, ); - final dividers = tester.widgetList( + expect( find.descendant( of: find.byKey(const Key('mine-menu')), - matching: find.byType(Divider), + matching: find.byType(ListTile), ), + findsNothing, ); - expect(dividers, hasLength(6)); + final positions = [ + for (final entryKey in entryKeys) tester.getTopLeft(find.byKey(entryKey)), + ]; expect( - dividers.every((divider) => divider.color == AppColors.contentSurface), - isTrue, + positions.take(4).map((position) => position.dy).toSet(), + hasLength(1), ); + expect( + positions.skip(4).map((position) => position.dy).toSet(), + hasLength(1), + ); + expect(positions[1].dx, greaterThan(positions[0].dx)); + expect(positions[2].dx, greaterThan(positions[1].dx)); + expect(positions[3].dx, greaterThan(positions[2].dx)); + expect(positions[5].dx, greaterThan(positions[4].dx)); + expect(positions[6].dx, greaterThan(positions[5].dx)); + expect(tester.getTopLeft(find.byKey(const Key('mine-menu'))).dx, 14); final menu = tester.widget(find.byKey(const Key('mine-menu'))); final menuDecoration = menu.decoration! as BoxDecoration; - expect(menuDecoration.color, AppColors.mineCard); + expect(menuDecoration.color, AppColors.mineGridSurface); expect( menuDecoration.borderRadius, - BorderRadius.circular(AppDimens.mineCardRadius), + BorderRadius.circular(AppDimens.mineGridRadius), ); + expect(menuDecoration.border, isNull); expect(menuDecoration.boxShadow, isNull); + final table = tester.widget(find.byKey(const Key('mine-menu-grid'))); + final border = table.border!; + expect(border.horizontalInside.color, AppColors.mineGridDivider); + expect(border.verticalInside.color, AppColors.mineGridDivider); final vip = tester.widget(find.byKey(const Key('mine-vip'))); final vipDecoration = vip.decoration! as BoxDecoration; expect((vipDecoration.gradient! as LinearGradient).colors, const [ @@ -179,7 +212,6 @@ void main() { child: MineMenuList( onOpen: opened.add, onLanguage: () => languageOpened++, - languageLabel: 'English', ), ), ),