merge: 对齐四 Tab 底栏布局规格
This commit is contained in:
commit
6dea9d479e
11 files changed
+135
-106
No files matched your search
@@ -831,3 +831,35 @@ $ flutter test --no-pub
|
||||
|
||||
解读:静态分析零告警,644 项测试全部通过;追剧与首页/推荐/我的均在同一根页面
|
||||
内切换,无叠加导航或返回栈。`pubspec.lock` 未提交,未启动或停止服务,未操作真机。
|
||||
|
||||
## 2026-10-22 根导航布局规格对齐
|
||||
|
||||
- 分支:`feature/client-bottom-nav-layout`
|
||||
- 基线:`develop` / `origin/develop` = `667fdc5`
|
||||
- 实现提交:`81787dc style(nav): 对齐四 Tab 底栏布局规格`
|
||||
|
||||
### 改动
|
||||
|
||||
- 新增 `AppRootBottomNav` 作为首页、推荐、追剧、我的唯一根导航入口,
|
||||
三个 Shell 页面不再各自拼装 item。
|
||||
- 四列使用 `Expanded` 等分,图标在上、文字在下,间距 4。
|
||||
- 内容高度 56,另由 `SafeArea` 增加底部安全区;图标 26、文字 11、
|
||||
`letterSpacing = 0`,选中 `w600`、未选中 `w400`。
|
||||
- 背景纯黑,取消顶部边框;选中 `#FFFFFF`,未选中 `#8E8E93`。
|
||||
- 首页、推荐、书签、人像继续使用各自的选中实心/未选中描边图标。
|
||||
|
||||
### 门禁命令与原始输出
|
||||
|
||||
```text
|
||||
$ flutter analyze
|
||||
Analyzing reelshort-bottom-nav...
|
||||
No issues found! (ran in 5.2s)
|
||||
```
|
||||
|
||||
```text
|
||||
$ flutter test
|
||||
04:43 +645: All tests passed!
|
||||
```
|
||||
|
||||
解读:四等分、56 内容高度、安全区、26 图标、4 间距、精确色值和无分割线
|
||||
均有测试覆盖;645 项测试全部通过。`pubspec.lock` 未提交,未操作真机。
|
||||
@@ -69,10 +69,9 @@ abstract final class AppColors {
|
||||
/// 底部导航底色。
|
||||
static const bar = ink;
|
||||
|
||||
/// 底部导航专用:纯黑背景、选中白、未选中灰与轻分割线。
|
||||
/// 底部导航专用:纯黑背景、选中白与未选中灰。
|
||||
static const navBackground = Color(0xFF000000);
|
||||
static const navSelected = Color(0xFFFFFFFF);
|
||||
static const navDivider = Color(0xFF1C1C1E);
|
||||
|
||||
/// 卡片阴影(深色几乎不用阴影)。
|
||||
static const cardShadow = Color(0x00000000);
|
||||
@@ -126,7 +125,7 @@ abstract final class AppColors {
|
||||
static const badgeBeige = raised;
|
||||
|
||||
/// 导航未选中。
|
||||
static const navUnselected = Color(0xFF8A8A8E);
|
||||
static const navUnselected = Color(0xFF8E8E93);
|
||||
|
||||
/// 播放器次要文字。
|
||||
static const playerMuted = Color(0xA3FFFFFF);
|
||||
|
||||
@@ -37,6 +37,7 @@ abstract final class AppDimens {
|
||||
static const double previewSectionTitle = 14;
|
||||
static const double chipFont = 10;
|
||||
static const double navLabel = 11;
|
||||
static const double navLabelGap = 4;
|
||||
|
||||
static const double buttonHeight = 48;
|
||||
static const double primaryButtonHeight = 52;
|
||||
@@ -47,8 +48,8 @@ abstract final class AppDimens {
|
||||
static const double tapTarget = 44;
|
||||
static const double contentHeaderHeight = 46;
|
||||
|
||||
static const double navHeight = 68;
|
||||
static const double navIcon = 24;
|
||||
static const double navHeight = 56;
|
||||
static const double navIcon = 26;
|
||||
static const double progressHeight = 3;
|
||||
static const double progressRadius = 2;
|
||||
static const double progressLineHeight = 3;
|
||||
|
||||
@@ -26,30 +26,17 @@ class AppBottomNavItem {
|
||||
|
||||
/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({
|
||||
super.key,
|
||||
required this.items,
|
||||
this.backgroundColor = AppColors.navBackground,
|
||||
this.barKey,
|
||||
this.dark = false,
|
||||
});
|
||||
const AppBottomNav({super.key, required this.items, this.barKey});
|
||||
|
||||
final List<AppBottomNavItem> items;
|
||||
final Color backgroundColor;
|
||||
final Key? barKey;
|
||||
final bool dark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bar = Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: dark ? AppColors.navBackground : backgroundColor,
|
||||
border: const Border(
|
||||
top: BorderSide(color: AppColors.navDivider, width: 0.5),
|
||||
),
|
||||
),
|
||||
color: AppColors.navBackground,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
@@ -99,7 +86,7 @@ class _NavDestination extends StatelessWidget {
|
||||
: unselectedColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: AppDimens.navLabelGap),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'app_bottom_nav.dart';
|
||||
|
||||
/// 客户端根导航的固定顺序。
|
||||
enum AppRootTab { home, recommend, following, mine }
|
||||
@@ -36,3 +37,36 @@ extension AppRootTabUi on AppRootTab {
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import '../../core/formatting/error_text.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.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_root_tab.dart';
|
||||
import '../../core/widgets/catalog_debug_info.dart';
|
||||
@@ -160,7 +159,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
loading: logic.searchLoading,
|
||||
),
|
||||
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));
|
||||
}
|
||||
|
||||
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
||||
items: [
|
||||
for (final tab in AppRootTab.values)
|
||||
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)),
|
||||
),
|
||||
],
|
||||
Widget _bottomNav() => AppRootBottomNav(
|
||||
selected: AppRootTab.recommend,
|
||||
onSelected: (tab) => _act(_actionOf(tab)),
|
||||
);
|
||||
|
||||
DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/platform/haptics.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_bottom_nav.dart';
|
||||
import '../../core/widgets/app_root_tab.dart';
|
||||
import '../../core/widgets/catalog_debug_info.dart';
|
||||
import '../../core/widgets/catalog_endpoint_editor.dart';
|
||||
@@ -262,7 +261,6 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
Widget _scaffold(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final feed = logic.tab == 0;
|
||||
final content = PopScope(
|
||||
canPop: false,
|
||||
@@ -275,7 +273,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
backgroundColor: AppColors.transparent,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: _body(context),
|
||||
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
|
||||
bottomNavigationBar: logic.videoImmersive ? null : _nav(context),
|
||||
),
|
||||
);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
@@ -451,31 +449,11 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nav(BuildContext context, AppLocalizations l10n) {
|
||||
return AppBottomNav(
|
||||
Widget _nav(BuildContext context) {
|
||||
return AppRootBottomNav(
|
||||
barKey: const Key('home-tabs-bar'),
|
||||
dark: logic.tab == 0,
|
||||
items: [
|
||||
_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),
|
||||
selected: AppRootTab.values[logic.tab],
|
||||
onSelected: (tab) => _selectTab(context, tab.index),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_bottom_nav.dart';
|
||||
import '../../core/widgets/app_root_tab.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/plain_page_scaffold.dart';
|
||||
@@ -87,7 +86,7 @@ class _MyPageState extends State<MyPage> {
|
||||
children: [
|
||||
_topBar(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())));
|
||||
}
|
||||
|
||||
Widget _bottomNav(AppLocalizations l10n) {
|
||||
return AppBottomNav(
|
||||
items: [
|
||||
for (final tab in AppRootTab.values)
|
||||
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)),
|
||||
),
|
||||
],
|
||||
Widget _bottomNav() {
|
||||
return AppRootBottomNav(
|
||||
selected: AppRootTab.mine,
|
||||
onSelected: (tab) => _act(_actionOf(tab)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,22 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
|
||||
|
||||
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', (
|
||||
tester,
|
||||
) async {
|
||||
var theaterTaps = 0;
|
||||
const barKey = Key('nav-bar');
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
barKey: barKey,
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-home'),
|
||||
@@ -60,6 +68,8 @@ void main() {
|
||||
final selectedLabel = tester.widget<Text>(find.text('Home'));
|
||||
expect(selectedLabel.style?.color, AppColors.navSelected);
|
||||
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'));
|
||||
expect(unselectedLabel.style?.color, AppColors.navUnselected);
|
||||
expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
|
||||
@@ -70,20 +80,35 @@ void main() {
|
||||
Key('tab-mine'),
|
||||
].map((key) => tester.getCenter(find.byKey(key)).dx).toList();
|
||||
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(
|
||||
tester.getSize(find.byType(AppBottomNav)).height,
|
||||
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(
|
||||
tester
|
||||
.widget<Icon>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-home')),
|
||||
matching: find.byIcon(Icons.movie_rounded),
|
||||
),
|
||||
)
|
||||
.color,
|
||||
AppColors.navSelected,
|
||||
tester.getSize(find.byIcon(Icons.movie_rounded)),
|
||||
const Size(26, 26),
|
||||
);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Home')).dy -
|
||||
tester.getBottomLeft(find.byIcon(Icons.movie_rounded)).dy,
|
||||
AppDimens.navLabelGap,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||
expect(theaterTaps, 1);
|
||||
@@ -99,7 +124,6 @@ void main() {
|
||||
data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)),
|
||||
child: Scaffold(
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
dark: true,
|
||||
barKey: barKey,
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
@@ -144,12 +168,15 @@ void main() {
|
||||
);
|
||||
expect(tester.getSize(find.byKey(barKey)).height, AppDimens.navHeight);
|
||||
|
||||
final coloredBox = tester.widget<ColoredBox>(
|
||||
expect(
|
||||
find.descendant(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -85,12 +85,8 @@ void main() {
|
||||
|
||||
final barFinder = find.byKey(const Key('home-tabs-bar'));
|
||||
final bar = tester.widget<Container>(barFinder);
|
||||
final decoration = bar.decoration as BoxDecoration;
|
||||
final border = decoration.border as Border;
|
||||
// 所有页面统一深色导航。
|
||||
expect(decoration.color, AppColors.navBackground);
|
||||
expect(border.top.color, AppColors.navDivider);
|
||||
expect(border.top.width, 0.5);
|
||||
expect(bar.color, AppColors.navBackground);
|
||||
expect(bar.decoration, isNull);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
@@ -158,12 +154,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(haptics.selectionClicks, 1);
|
||||
final theaterBar = tester.widget<Container>(barFinder);
|
||||
final theaterDecoration = theaterBar.decoration as BoxDecoration;
|
||||
expect(theaterDecoration.color, AppColors.navBackground);
|
||||
expect(
|
||||
(theaterDecoration.border as Border).top.color,
|
||||
AppColors.navDivider,
|
||||
);
|
||||
expect(theaterBar.color, AppColors.navBackground);
|
||||
expect(theaterBar.decoration, isNull);
|
||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(haptics.selectionClicks, 1);
|
||||
|
||||
@@ -19,11 +19,12 @@ void main() {
|
||||
expect(AppDimens.sectionTitle, 16);
|
||||
expect(AppDimens.pageTitle, 20);
|
||||
expect(AppDimens.navLabel, 11);
|
||||
expect(AppDimens.navIcon, 24);
|
||||
expect(AppDimens.navLabelGap, 4);
|
||||
expect(AppDimens.navIcon, 26);
|
||||
expect(AppDimens.buttonHeight, 48);
|
||||
expect(AppDimens.primaryButtonHeight, 52);
|
||||
expect(AppDimens.tapTarget, 44);
|
||||
expect(AppDimens.navHeight, 68);
|
||||
expect(AppDimens.navHeight, 56);
|
||||
expect(AppDimens.progressHeight, 3);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user