merge: 改造我的页渐变与图标网格
This commit is contained in:
commit
647f6afd77
6 files changed
+244
-179
No files matched your search
@@ -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。
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user