From 7463d3ee262c424d0a4b69845fa0eeb3bbd9d185 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 2 Nov 2026 14:44:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(empty-state):=20=E7=A9=BA=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=94=AF=E6=8C=81=E5=86=85=E5=AE=B9=E5=B1=85=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/empty_state.dart | 67 +++++++++++-------- lib/pages/discover/discover_view.dart | 1 + lib/pages/favorites/favorites_page.dart | 59 ++++++++-------- lib/pages/recommend/recommend_page.dart | 1 + .../watch_records/watch_records_page.dart | 48 ++++++------- test/empty_state/empty_state_test.dart | 6 +- test/library/favorites_page_test.dart | 8 +++ test/watch/watch_records_page_test.dart | 21 ++++++ 8 files changed, 131 insertions(+), 80 deletions(-) diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart index 7efd58a..d11ee4e 100644 --- a/lib/core/widgets/empty_state.dart +++ b/lib/core/widgets/empty_state.dart @@ -5,12 +5,12 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; import '../theme/app_dimens.dart'; -const double _regularIconSize = 72; -const double _compactIconSize = 44; +const double _iconSize = 72; const double _maxContentWidth = 260; const double _regularHorizontal = 24; const double _regularVertical = 28; -const double _compactPad = 16; +const double _compactHorizontal = 16; +const double _compactVertical = 4; const double _regularGap = 20; const double _compactGap = 12; const double _actionMinHeight = 44; @@ -27,6 +27,7 @@ class EmptyState extends StatelessWidget { this.onAction, this.footer, this.compact = false, + this.stretchContent = false, }); final IconData icon; @@ -38,6 +39,9 @@ class EmptyState extends StatelessWidget { final Widget? footer; final bool compact; + /// 让图标和文案占满内容宽度,适用于需要严格居中的宽版空状态。 + final bool stretchContent; + @override Widget build(BuildContext context) { final hasAction = actionLabel != null && onAction != null; @@ -48,28 +52,33 @@ class EmptyState extends StatelessWidget { child: Padding( key: const Key('empty-state-padding'), padding: EdgeInsets.symmetric( - horizontal: compact ? _compactPad : _regularHorizontal, - vertical: compact ? _compactPad : _regularVertical, + horizontal: compact ? _compactHorizontal : _regularHorizontal, + vertical: compact ? _compactVertical : _regularVertical, ), - child: Semantics( - container: true, - label: title, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ExcludeSemantics( - child: _EmptyMark(icon: icon, compact: compact), - ), - SizedBox(height: compact ? _compactGap : _regularGap), - _EmptyCopy( - title: title, - subtitle: subtitle, - actionLabel: hasAction ? actionLabel : null, - actionKey: actionKey, - onAction: hasAction ? onAction : null, - footer: footer, - ), - ], + child: SizedBox( + width: stretchContent ? double.infinity : null, + child: Semantics( + container: true, + label: title, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics(child: _EmptyMark(icon: icon)), + SizedBox(height: compact ? _compactGap : _regularGap), + SizedBox( + width: stretchContent ? double.infinity : null, + child: _EmptyCopy( + title: title, + subtitle: subtitle, + actionLabel: hasAction ? actionLabel : null, + actionKey: actionKey, + onAction: hasAction ? onAction : null, + footer: footer, + stretchContent: stretchContent, + ), + ), + ], + ), ), ), ), @@ -80,17 +89,16 @@ class EmptyState extends StatelessWidget { /// 空列表只放一枚大图标,不画线框,也不铺底色。 class _EmptyMark extends StatelessWidget { - const _EmptyMark({required this.icon, required this.compact}); + const _EmptyMark({required this.icon}); final IconData icon; - final bool compact; @override Widget build(BuildContext context) { return Icon( icon, key: const Key('empty-state-icon'), - size: compact ? _compactIconSize : _regularIconSize, + size: _iconSize, color: AppColors.subtle, ); } @@ -104,6 +112,7 @@ class _EmptyCopy extends StatelessWidget { required this.actionKey, required this.onAction, required this.footer, + required this.stretchContent, }); final String title; @@ -112,11 +121,15 @@ class _EmptyCopy extends StatelessWidget { final Key? actionKey; final VoidCallback? onAction; final Widget? footer; + final bool stretchContent; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: stretchContent + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.center, children: [ ExcludeSemantics( child: Text( diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index fff3617..a849f09 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -297,6 +297,7 @@ class _DiscoverPageState extends State { icon: Icons.movie_filter_outlined, title: l10n.noStories, subtitle: l10n.emptyHint, + stretchContent: true, ), ), ) diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index c4559e1..d37b9c3 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -38,7 +38,6 @@ const double _synopsisFontSize = 12; const double _synopsisLineHeight = 1.15; const double _synopsisGap = 4; const int _maxTags = 2; -const double _emptyListHeight = 360; const double _loadMoreThreshold = 240; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -124,37 +123,29 @@ class _FavoritesBody extends StatelessWidget { ); } if (favorites.error != null && favorites.cards.isEmpty) { - return _refreshable( + return _refreshableState( favorites, - children: [ - SizedBox( - height: _emptyListHeight, - child: EmptyState( - key: const Key('favorites-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - actionKey: const Key('favorites-retry'), - onAction: () => unawaited(favorites.load()), - ), - ), - ], + child: EmptyState( + key: const Key('favorites-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + actionKey: const Key('favorites-retry'), + onAction: () => unawaited(favorites.load()), + stretchContent: true, + ), ); } if (favorites.cards.isEmpty) { - return _refreshable( + return _refreshableState( favorites, - children: [ - SizedBox( - height: _emptyListHeight, - child: EmptyState( - key: const Key('favorites-empty'), - icon: Icons.star_border_rounded, - title: l10n.noFavorites, - subtitle: l10n.emptyHint, - ), - ), - ], + child: EmptyState( + key: const Key('favorites-empty'), + icon: Icons.star_border_rounded, + title: l10n.noFavorites, + subtitle: l10n.emptyHint, + stretchContent: true, + ), ); } return _refreshable( @@ -205,6 +196,20 @@ class _FavoritesBody extends StatelessWidget { ); } + Widget _refreshableState( + FavoriteController favorites, { + required Widget child, + }) { + return AppPullToRefresh( + onRefresh: favorites.load, + child: CustomScrollView( + key: const Key('favorites-empty-scroll'), + physics: AppPullToRefresh.physics, + slivers: [SliverFillRemaining(hasScrollBody: false, child: child)], + ), + ); + } + bool _onScroll(ScrollNotification notification) { if (notification.depth != 0) return false; final reachedBottom = diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index e7f136e..26e9a14 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -348,6 +348,7 @@ class _RecommendPageState extends State key: const Key('recommend-feed-empty'), icon: Icons.movie_outlined, title: l10n.noStories, + stretchContent: true, ), ); } diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index d8e2e7d..8b31ad2 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -26,7 +26,6 @@ import 'widgets/watch_record_card.dart'; const double _pageHorizontal = 14; const double _statsFontSize = 12; -const double _emptyListHeight = 360; const double _loadMoreThreshold = 240; /// 独立观看记录页。 @@ -113,31 +112,22 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ); } if (controller.error != null && controller.records.isEmpty) { - return _refreshable( - children: [ - SizedBox( - height: _emptyListHeight, - child: _FailureState( - message: errorText(l10n, controller.error, l10n.progressError), - onRetry: controller.load, - ), - ), - ], + return _refreshableState( + child: _FailureState( + message: errorText(l10n, controller.error, l10n.progressError), + onRetry: controller.load, + ), ); } if (controller.records.isEmpty) { - return _refreshable( - children: [ - SizedBox( - height: _emptyListHeight, - child: EmptyState( - key: const Key('watch-records-empty'), - icon: Icons.history_rounded, - title: l10n.noWatchRecords, - subtitle: l10n.emptyHint, - ), - ), - ], + return _refreshableState( + child: EmptyState( + key: const Key('watch-records-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + stretchContent: true, + ), ); } return _refreshable( @@ -184,6 +174,17 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ); } + Widget _refreshableState({required Widget child}) { + return AppPullToRefresh( + onRefresh: controller.load, + child: CustomScrollView( + key: const Key('watch-records-empty-scroll'), + physics: AppPullToRefresh.physics, + slivers: [SliverFillRemaining(hasScrollBody: false, child: child)], + ), + ); + } + bool _onScroll(ScrollNotification notification) { if (notification.depth != 0) return false; final reachedBottom = @@ -303,6 +304,7 @@ class _FailureState extends StatelessWidget { actionLabel: l10n.retry, actionKey: const Key('watch-records-retry'), onAction: () => unawaited(onRetry()), + stretchContent: true, ); } } diff --git a/test/empty_state/empty_state_test.dart b/test/empty_state/empty_state_test.dart index 1c32c9d..8e5027b 100644 --- a/test/empty_state/empty_state_test.dart +++ b/test/empty_state/empty_state_test.dart @@ -69,7 +69,7 @@ void main() { semantics.dispose(); }); - testWidgets('should_render_compact_dimensions_for_embedded_empty_state', ( + testWidgets('should_keep_standard_icon_size_in_compact_empty_state', ( tester, ) async { await _pump( @@ -83,7 +83,7 @@ void main() { expect( tester.getSize(find.byKey(const Key('empty-state-icon'))), - const Size(44, 44), + const Size(72, 72), ); expect(find.byType(DecoratedBox), findsNothing); expect( @@ -97,7 +97,7 @@ void main() { tester .widget(find.byKey(const Key('empty-state-padding'))) .padding, - const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); expect(find.byKey(const Key('empty-state-action')), findsNothing); }); diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 69c08ce..f0306bb 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -174,6 +174,14 @@ void main() { expect(find.byKey(const Key('favorites-empty')), findsOneWidget); expect(find.byType(EmptyState), findsOneWidget); + final viewport = tester.getRect( + find.byKey(const Key('favorites-empty-scroll')), + ); + final content = tester.getRect( + find.byKey(const Key('empty-state-content')), + ); + expect(content.center.dx, closeTo(viewport.center.dx, 0.1)); + expect(content.center.dy, closeTo(viewport.center.dy, 0.1)); }); testWidgets('should_show_retry_when_favorite_list_fails', (tester) async { diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index e797107..e7deeee 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -209,6 +209,27 @@ void main() { expect(find.text('The Signal'), findsOneWidget); }); + testWidgets('should_center_empty_state_in_remaining_page_space', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + await _pumpPage( + tester, + auth: auth, + history: CountingWatchHistory(const []), + catalog: FakeCatalogRepository(), + ); + + final viewport = tester.getRect( + find.byKey(const Key('watch-records-empty-scroll')), + ); + final content = tester.getRect( + find.byKey(const Key('empty-state-content')), + ); + expect(content.center.dx, closeTo(viewport.center.dx, 0.1)); + expect(content.center.dy, closeTo(viewport.center.dy, 0.1)); + }); + testWidgets('should_remove_and_clear_device_history_when_signed_out', ( tester, ) async {