style(nav): 对齐四 Tab 底栏布局规格

This commit is contained in:
Zhou Weidong committed 2026-10-22 16:38:14 +08:00
1 parent 667fdc5f0a
commit 81787dc3aa
10 files changed
+103 -106

No files matched your search

+2 -3
View File
@@ -69,10 +69,9 @@ abstract final class AppColors {
/// 底部导航底色。 /// 底部导航底色。
static const bar = ink; static const bar = ink;
/// 底部导航专用:纯黑背景、选中白、未选中灰与轻分割线。 /// 底部导航专用:纯黑背景、选中白与未选中灰。
static const navBackground = Color(0xFF000000); static const navBackground = Color(0xFF000000);
static const navSelected = Color(0xFFFFFFFF); static const navSelected = Color(0xFFFFFFFF);
static const navDivider = Color(0xFF1C1C1E);
/// 卡片阴影(深色几乎不用阴影)。 /// 卡片阴影(深色几乎不用阴影)。
static const cardShadow = Color(0x00000000); static const cardShadow = Color(0x00000000);
@@ -126,7 +125,7 @@ abstract final class AppColors {
static const badgeBeige = raised; static const badgeBeige = raised;
/// 导航未选中。 /// 导航未选中。
static const navUnselected = Color(0xFF8A8A8E); static const navUnselected = Color(0xFF8E8E93);
/// 播放器次要文字。 /// 播放器次要文字。
static const playerMuted = Color(0xA3FFFFFF); static const playerMuted = Color(0xA3FFFFFF);
+3 -2
View File
@@ -37,6 +37,7 @@ abstract final class AppDimens {
static const double previewSectionTitle = 14; static const double previewSectionTitle = 14;
static const double chipFont = 10; static const double chipFont = 10;
static const double navLabel = 11; static const double navLabel = 11;
static const double navLabelGap = 4;
static const double buttonHeight = 48; static const double buttonHeight = 48;
static const double primaryButtonHeight = 52; static const double primaryButtonHeight = 52;
@@ -47,8 +48,8 @@ abstract final class AppDimens {
static const double tapTarget = 44; static const double tapTarget = 44;
static const double contentHeaderHeight = 46; static const double contentHeaderHeight = 46;
static const double navHeight = 68; static const double navHeight = 56;
static const double navIcon = 24; static const double navIcon = 26;
static const double progressHeight = 3; static const double progressHeight = 3;
static const double progressRadius = 2; static const double progressRadius = 2;
static const double progressLineHeight = 3; static const double progressLineHeight = 3;
+3 -16
View File
@@ -26,30 +26,17 @@ class AppBottomNavItem {
/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。 /// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。
class AppBottomNav extends StatelessWidget { class AppBottomNav extends StatelessWidget {
const AppBottomNav({ const AppBottomNav({super.key, required this.items, this.barKey});
super.key,
required this.items,
this.backgroundColor = AppColors.navBackground,
this.barKey,
this.dark = false,
});
final List<AppBottomNavItem> items; final List<AppBottomNavItem> items;
final Color backgroundColor;
final Key? barKey; final Key? barKey;
final bool dark;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bar = Container( final bar = Container(
key: barKey, key: barKey,
height: AppDimens.navHeight, height: AppDimens.navHeight,
decoration: BoxDecoration( color: AppColors.navBackground,
color: dark ? AppColors.navBackground : backgroundColor,
border: const Border(
top: BorderSide(color: AppColors.navDivider, width: 0.5),
),
),
child: Row( child: Row(
children: [ children: [
for (final item in items) for (final item in items)
@@ -99,7 +86,7 @@ class _NavDestination extends StatelessWidget {
: unselectedColor, : unselectedColor,
), ),
), ),
const SizedBox(height: 3), const SizedBox(height: AppDimens.navLabelGap),
Text( Text(
item.label, item.label,
maxLines: 1, maxLines: 1,
+34
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'app_bottom_nav.dart';
/// 客户端根导航的固定顺序。 /// 客户端根导航的固定顺序。
enum AppRootTab { home, recommend, following, mine } enum AppRootTab { home, recommend, following, mine }
@@ -36,3 +37,36 @@ extension AppRootTabUi on AppRootTab {
AppRootTab.mine => l10n.myTab, AppRootTab.mine => l10n.myTab,
}; };
} }
/// 根导航唯一入口,确保四个主页面使用完全一致的底栏配置。
class AppRootBottomNav extends StatelessWidget {
const AppRootBottomNav({
super.key,
required this.selected,
required this.onSelected,
this.barKey,
});
final AppRootTab selected;
final ValueChanged<AppRootTab> onSelected;
final Key? barKey;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AppBottomNav(
barKey: barKey,
items: [
for (final tab in AppRootTab.values)
AppBottomNavItem(
tabKey: tab.tabKey,
icon: tab.icon,
selectedIcon: tab.selectedIcon,
label: tab.label(l10n),
selected: tab == selected,
onTap: () => onSelected(tab),
),
],
);
}
}
+4 -16
View File
@@ -17,7 +17,6 @@ import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_debug_info.dart';
@@ -160,7 +159,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
loading: logic.searchLoading, loading: logic.searchLoading,
), ),
Expanded(child: _feed(l10n, categories)), Expanded(child: _feed(l10n, categories)),
if (!embedded) _bottomNav(l10n), if (!embedded) _bottomNav(),
], ],
), ),
), ),
@@ -612,20 +611,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
unawaited(playDramaFromCatalog(context, drama)); unawaited(playDramaFromCatalog(context, drama));
} }
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( Widget _bottomNav() => AppRootBottomNav(
items: [ selected: AppRootTab.recommend,
for (final tab in AppRootTab.values) onSelected: (tab) => _act(_actionOf(tab)),
AppBottomNavItem(
tabKey: tab.tabKey,
icon: tab.icon,
selectedIcon: tab.selectedIcon,
label: tab.label(l10n),
selected: tab == AppRootTab.recommend,
onTap: tab == AppRootTab.recommend
? () {}
: () => _act(_actionOf(tab)),
),
],
); );
DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) { DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) {
+5 -27
View File
@@ -16,7 +16,6 @@ import '../../core/data/services/category_controller.dart';
import '../../core/platform/haptics.dart'; import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_debug_info.dart';
import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/catalog_endpoint_editor.dart';
@@ -262,7 +261,6 @@ class _HomeTabsState extends State<_HomeTabs> {
} }
Widget _scaffold(BuildContext context) { Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final feed = logic.tab == 0; final feed = logic.tab == 0;
final content = PopScope( final content = PopScope(
canPop: false, canPop: false,
@@ -275,7 +273,7 @@ class _HomeTabsState extends State<_HomeTabs> {
backgroundColor: AppColors.transparent, backgroundColor: AppColors.transparent,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: _body(context), body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), bottomNavigationBar: logic.videoImmersive ? null : _nav(context),
), ),
); );
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
@@ -451,31 +449,11 @@ class _HomeTabsState extends State<_HomeTabs> {
); );
} }
Widget _nav(BuildContext context, AppLocalizations l10n) { Widget _nav(BuildContext context) {
return AppBottomNav( return AppRootBottomNav(
barKey: const Key('home-tabs-bar'), barKey: const Key('home-tabs-bar'),
dark: logic.tab == 0, selected: AppRootTab.values[logic.tab],
items: [ onSelected: (tab) => _selectTab(context, tab.index),
_navItem(context, l10n, AppRootTab.home),
_navItem(context, l10n, AppRootTab.recommend),
_navItem(context, l10n, AppRootTab.following),
_navItem(context, l10n, AppRootTab.mine),
],
);
}
AppBottomNavItem _navItem(
BuildContext context,
AppLocalizations l10n,
AppRootTab tab,
) {
return AppBottomNavItem(
tabKey: tab.tabKey,
icon: tab.icon,
selectedIcon: tab.selectedIcon,
label: tab.label(l10n),
selected: logic.tab == tab.index,
onTap: () => _selectTab(context, tab.index),
); );
} }
+5 -15
View File
@@ -12,7 +12,6 @@ import '../../core/data/services/wallet_controller.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/plain_page_scaffold.dart';
@@ -87,7 +86,7 @@ class _MyPageState extends State<MyPage> {
children: [ children: [
_topBar(l10n), _topBar(l10n),
Expanded(child: _body(context, l10n)), Expanded(child: _body(context, l10n)),
if (!embedded) _bottomNav(l10n), if (!embedded) _bottomNav(),
], ],
), ),
); );
@@ -238,19 +237,10 @@ class _MyPageState extends State<MyPage> {
_openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load())));
} }
Widget _bottomNav(AppLocalizations l10n) { Widget _bottomNav() {
return AppBottomNav( return AppRootBottomNav(
items: [ selected: AppRootTab.mine,
for (final tab in AppRootTab.values) onSelected: (tab) => _act(_actionOf(tab)),
AppBottomNavItem(
tabKey: tab.tabKey,
icon: tab.icon,
selectedIcon: tab.selectedIcon,
label: tab.label(l10n),
selected: tab == AppRootTab.mine,
onTap: tab == AppRootTab.mine ? () {} : () => _act(_actionOf(tab)),
),
],
); );
} }
+40 -13
View File
@@ -7,14 +7,22 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
void main() { void main() {
test('should_use_exact_root_navigation_colors', () {
expect(AppColors.navBackground, const Color(0xFF000000));
expect(AppColors.navSelected, const Color(0xFFFFFFFF));
expect(AppColors.navUnselected, const Color(0xFF8E8E93));
});
testWidgets('should_render_four_slots_with_white_selected_state', ( testWidgets('should_render_four_slots_with_white_selected_state', (
tester, tester,
) async { ) async {
var theaterTaps = 0; var theaterTaps = 0;
const barKey = Key('nav-bar');
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
bottomNavigationBar: AppBottomNav( bottomNavigationBar: AppBottomNav(
barKey: barKey,
items: [ items: [
AppBottomNavItem( AppBottomNavItem(
tabKey: const Key('tab-home'), tabKey: const Key('tab-home'),
@@ -60,6 +68,8 @@ void main() {
final selectedLabel = tester.widget<Text>(find.text('Home')); final selectedLabel = tester.widget<Text>(find.text('Home'));
expect(selectedLabel.style?.color, AppColors.navSelected); expect(selectedLabel.style?.color, AppColors.navSelected);
expect(selectedLabel.style?.fontWeight, FontWeight.w600); expect(selectedLabel.style?.fontWeight, FontWeight.w600);
expect(selectedLabel.style?.fontSize, AppDimens.navLabel);
expect(selectedLabel.style?.letterSpacing, 0);
final unselectedLabel = tester.widget<Text>(find.text('Recommend')); final unselectedLabel = tester.widget<Text>(find.text('Recommend'));
expect(unselectedLabel.style?.color, AppColors.navUnselected); expect(unselectedLabel.style?.color, AppColors.navUnselected);
expect(unselectedLabel.style?.fontWeight, FontWeight.w400); expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
@@ -70,20 +80,35 @@ void main() {
Key('tab-mine'), Key('tab-mine'),
].map((key) => tester.getCenter(find.byKey(key)).dx).toList(); ].map((key) => tester.getCenter(find.byKey(key)).dx).toList();
expect(centers, orderedEquals(centers.toList()..sort())); expect(centers, orderedEquals(centers.toList()..sort()));
final widths = [
Key('tab-home'),
Key('tab-recommend'),
Key('tab-follow'),
Key('tab-mine'),
].map((key) => tester.getSize(find.byKey(key)).width).toList();
expect(widths.toSet(), hasLength(1));
expect( expect(
tester.getSize(find.byType(AppBottomNav)).height, tester.getSize(find.byType(AppBottomNav)).height,
AppDimens.navHeight, AppDimens.navHeight,
); );
final bar = tester.widget<Container>(find.byKey(barKey));
expect(bar.color, AppColors.navBackground);
expect(bar.decoration, isNull);
final selectedIcon = tester.widget<Icon>(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.movie_rounded),
),
);
expect(selectedIcon.color, AppColors.navSelected);
expect( expect(
tester tester.getSize(find.byIcon(Icons.movie_rounded)),
.widget<Icon>( const Size(26, 26),
find.descendant( );
of: find.byKey(const Key('tab-home')), expect(
matching: find.byIcon(Icons.movie_rounded), tester.getTopLeft(find.text('Home')).dy -
), tester.getBottomLeft(find.byIcon(Icons.movie_rounded)).dy,
) AppDimens.navLabelGap,
.color,
AppColors.navSelected,
); );
await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.tap(find.byKey(const Key('tab-recommend')));
expect(theaterTaps, 1); expect(theaterTaps, 1);
@@ -99,7 +124,6 @@ void main() {
data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)), data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)),
child: Scaffold( child: Scaffold(
bottomNavigationBar: AppBottomNav( bottomNavigationBar: AppBottomNav(
dark: true,
barKey: barKey, barKey: barKey,
items: [ items: [
AppBottomNavItem( AppBottomNavItem(
@@ -144,12 +168,15 @@ void main() {
); );
expect(tester.getSize(find.byKey(barKey)).height, AppDimens.navHeight); expect(tester.getSize(find.byKey(barKey)).height, AppDimens.navHeight);
final coloredBox = tester.widget<ColoredBox>( expect(
find.descendant( find.descendant(
of: find.byType(AppBottomNav), of: find.byType(AppBottomNav),
matching: find.byType(ColoredBox), matching: find.byWidgetPredicate(
(widget) =>
widget is ColoredBox && widget.color == AppColors.navBackground,
),
), ),
findsWidgets,
); );
expect(coloredBox.color, AppColors.navBackground);
}); });
} }
+4 -12
View File
@@ -85,12 +85,8 @@ void main() {
final barFinder = find.byKey(const Key('home-tabs-bar')); final barFinder = find.byKey(const Key('home-tabs-bar'));
final bar = tester.widget<Container>(barFinder); final bar = tester.widget<Container>(barFinder);
final decoration = bar.decoration as BoxDecoration; expect(bar.color, AppColors.navBackground);
final border = decoration.border as Border; expect(bar.decoration, isNull);
// 所有页面统一深色导航。
expect(decoration.color, AppColors.navBackground);
expect(border.top.color, AppColors.navDivider);
expect(border.top.width, 0.5);
expect( expect(
tester tester
.widget<Text>( .widget<Text>(
@@ -158,12 +154,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(haptics.selectionClicks, 1); expect(haptics.selectionClicks, 1);
final theaterBar = tester.widget<Container>(barFinder); final theaterBar = tester.widget<Container>(barFinder);
final theaterDecoration = theaterBar.decoration as BoxDecoration; expect(theaterBar.color, AppColors.navBackground);
expect(theaterDecoration.color, AppColors.navBackground); expect(theaterBar.decoration, isNull);
expect(
(theaterDecoration.border as Border).top.color,
AppColors.navDivider,
);
await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(haptics.selectionClicks, 1); expect(haptics.selectionClicks, 1);
+3 -2
View File
@@ -19,11 +19,12 @@ void main() {
expect(AppDimens.sectionTitle, 16); expect(AppDimens.sectionTitle, 16);
expect(AppDimens.pageTitle, 20); expect(AppDimens.pageTitle, 20);
expect(AppDimens.navLabel, 11); expect(AppDimens.navLabel, 11);
expect(AppDimens.navIcon, 24); expect(AppDimens.navLabelGap, 4);
expect(AppDimens.navIcon, 26);
expect(AppDimens.buttonHeight, 48); expect(AppDimens.buttonHeight, 48);
expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.primaryButtonHeight, 52);
expect(AppDimens.tapTarget, 44); expect(AppDimens.tapTarget, 44);
expect(AppDimens.navHeight, 68); expect(AppDimens.navHeight, 56);
expect(AppDimens.progressHeight, 3); expect(AppDimens.progressHeight, 3);
}); });
} }