merge: 改造我的页渐变与图标网格

This commit is contained in:
Zhou Weidong committed 2026-10-22 21:35:20 +08:00
commit 647f6afd77
6 files changed
+244 -179

No files matched your search

+6
View File
@@ -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。
+7
View File
@@ -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);
+7 -2
View File
@@ -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;
}
+47 -31
View File
@@ -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<MyPage> {
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<SystemUiOverlayStyle>(
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<MyPage> {
);
}
Widget _body(BuildContext context, AppLocalizations l10n) {
final locale = context.watch<LocaleController>();
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<MyPage> {
_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<void> _openLanguage() async {
final selected = await showModalBottomSheet<Locale>(
context: context,
+125 -126
View File
@@ -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<MineDestination> 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,
),
),
),
],
),
),
),
);
}
}
+52 -20
View File
@@ -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<DecoratedBox>(
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<ListTile>(
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<Divider>(
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<Container>(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<Table>(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<Container>(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',
),
),
),