refactor(empty-state): 空状态支持内容居中

This commit is contained in:
Zhou Weidong committed 2026-11-02 14:44:38 +08:00
1 parent 59fbd33059
commit 7463d3ee26
8 files changed
+131 -80

No files matched your search

+40 -27
View File
@@ -5,12 +5,12 @@ import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_dimens.dart'; import '../theme/app_dimens.dart';
const double _regularIconSize = 72; const double _iconSize = 72;
const double _compactIconSize = 44;
const double _maxContentWidth = 260; const double _maxContentWidth = 260;
const double _regularHorizontal = 24; const double _regularHorizontal = 24;
const double _regularVertical = 28; const double _regularVertical = 28;
const double _compactPad = 16; const double _compactHorizontal = 16;
const double _compactVertical = 4;
const double _regularGap = 20; const double _regularGap = 20;
const double _compactGap = 12; const double _compactGap = 12;
const double _actionMinHeight = 44; const double _actionMinHeight = 44;
@@ -27,6 +27,7 @@ class EmptyState extends StatelessWidget {
this.onAction, this.onAction,
this.footer, this.footer,
this.compact = false, this.compact = false,
this.stretchContent = false,
}); });
final IconData icon; final IconData icon;
@@ -38,6 +39,9 @@ class EmptyState extends StatelessWidget {
final Widget? footer; final Widget? footer;
final bool compact; final bool compact;
/// 让图标和文案占满内容宽度,适用于需要严格居中的宽版空状态。
final bool stretchContent;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasAction = actionLabel != null && onAction != null; final hasAction = actionLabel != null && onAction != null;
@@ -48,28 +52,33 @@ class EmptyState extends StatelessWidget {
child: Padding( child: Padding(
key: const Key('empty-state-padding'), key: const Key('empty-state-padding'),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: compact ? _compactPad : _regularHorizontal, horizontal: compact ? _compactHorizontal : _regularHorizontal,
vertical: compact ? _compactPad : _regularVertical, vertical: compact ? _compactVertical : _regularVertical,
), ),
child: Semantics( child: SizedBox(
container: true, width: stretchContent ? double.infinity : null,
label: title, child: Semantics(
child: Column( container: true,
mainAxisSize: MainAxisSize.min, label: title,
children: [ child: Column(
ExcludeSemantics( mainAxisSize: MainAxisSize.min,
child: _EmptyMark(icon: icon, compact: compact), children: [
), ExcludeSemantics(child: _EmptyMark(icon: icon)),
SizedBox(height: compact ? _compactGap : _regularGap), SizedBox(height: compact ? _compactGap : _regularGap),
_EmptyCopy( SizedBox(
title: title, width: stretchContent ? double.infinity : null,
subtitle: subtitle, child: _EmptyCopy(
actionLabel: hasAction ? actionLabel : null, title: title,
actionKey: actionKey, subtitle: subtitle,
onAction: hasAction ? onAction : null, actionLabel: hasAction ? actionLabel : null,
footer: footer, actionKey: actionKey,
), onAction: hasAction ? onAction : null,
], footer: footer,
stretchContent: stretchContent,
),
),
],
),
), ),
), ),
), ),
@@ -80,17 +89,16 @@ class EmptyState extends StatelessWidget {
/// 空列表只放一枚大图标,不画线框,也不铺底色。 /// 空列表只放一枚大图标,不画线框,也不铺底色。
class _EmptyMark extends StatelessWidget { class _EmptyMark extends StatelessWidget {
const _EmptyMark({required this.icon, required this.compact}); const _EmptyMark({required this.icon});
final IconData icon; final IconData icon;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Icon( return Icon(
icon, icon,
key: const Key('empty-state-icon'), key: const Key('empty-state-icon'),
size: compact ? _compactIconSize : _regularIconSize, size: _iconSize,
color: AppColors.subtle, color: AppColors.subtle,
); );
} }
@@ -104,6 +112,7 @@ class _EmptyCopy extends StatelessWidget {
required this.actionKey, required this.actionKey,
required this.onAction, required this.onAction,
required this.footer, required this.footer,
required this.stretchContent,
}); });
final String title; final String title;
@@ -112,11 +121,15 @@ class _EmptyCopy extends StatelessWidget {
final Key? actionKey; final Key? actionKey;
final VoidCallback? onAction; final VoidCallback? onAction;
final Widget? footer; final Widget? footer;
final bool stretchContent;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: stretchContent
? CrossAxisAlignment.stretch
: CrossAxisAlignment.center,
children: [ children: [
ExcludeSemantics( ExcludeSemantics(
child: Text( child: Text(
+1
View File
@@ -297,6 +297,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
icon: Icons.movie_filter_outlined, icon: Icons.movie_filter_outlined,
title: l10n.noStories, title: l10n.noStories,
subtitle: l10n.emptyHint, subtitle: l10n.emptyHint,
stretchContent: true,
), ),
), ),
) )
+32 -27
View File
@@ -38,7 +38,6 @@ const double _synopsisFontSize = 12;
const double _synopsisLineHeight = 1.15; const double _synopsisLineHeight = 1.15;
const double _synopsisGap = 4; const double _synopsisGap = 4;
const int _maxTags = 2; const int _maxTags = 2;
const double _emptyListHeight = 360;
const double _loadMoreThreshold = 240; const double _loadMoreThreshold = 240;
final RegExp _tagSplitter = RegExp(r'[,/、·]'); final RegExp _tagSplitter = RegExp(r'[,/、·]');
@@ -124,37 +123,29 @@ class _FavoritesBody extends StatelessWidget {
); );
} }
if (favorites.error != null && favorites.cards.isEmpty) { if (favorites.error != null && favorites.cards.isEmpty) {
return _refreshable( return _refreshableState(
favorites, favorites,
children: [ child: EmptyState(
SizedBox( key: const Key('favorites-error'),
height: _emptyListHeight, icon: Icons.cloud_off_rounded,
child: EmptyState( title: errorText(l10n, favorites.error, l10n.favoritesError),
key: const Key('favorites-error'), actionLabel: l10n.retry,
icon: Icons.cloud_off_rounded, actionKey: const Key('favorites-retry'),
title: errorText(l10n, favorites.error, l10n.favoritesError), onAction: () => unawaited(favorites.load()),
actionLabel: l10n.retry, stretchContent: true,
actionKey: const Key('favorites-retry'), ),
onAction: () => unawaited(favorites.load()),
),
),
],
); );
} }
if (favorites.cards.isEmpty) { if (favorites.cards.isEmpty) {
return _refreshable( return _refreshableState(
favorites, favorites,
children: [ child: EmptyState(
SizedBox( key: const Key('favorites-empty'),
height: _emptyListHeight, icon: Icons.star_border_rounded,
child: EmptyState( title: l10n.noFavorites,
key: const Key('favorites-empty'), subtitle: l10n.emptyHint,
icon: Icons.star_border_rounded, stretchContent: true,
title: l10n.noFavorites, ),
subtitle: l10n.emptyHint,
),
),
],
); );
} }
return _refreshable( 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) { bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false; if (notification.depth != 0) return false;
final reachedBottom = final reachedBottom =
+1
View File
@@ -348,6 +348,7 @@ class _RecommendPageState extends State<RecommendPage>
key: const Key('recommend-feed-empty'), key: const Key('recommend-feed-empty'),
icon: Icons.movie_outlined, icon: Icons.movie_outlined,
title: l10n.noStories, title: l10n.noStories,
stretchContent: true,
), ),
); );
} }
+25 -23
View File
@@ -26,7 +26,6 @@ import 'widgets/watch_record_card.dart';
const double _pageHorizontal = 14; const double _pageHorizontal = 14;
const double _statsFontSize = 12; const double _statsFontSize = 12;
const double _emptyListHeight = 360;
const double _loadMoreThreshold = 240; const double _loadMoreThreshold = 240;
/// 独立观看记录页。 /// 独立观看记录页。
@@ -113,31 +112,22 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
); );
} }
if (controller.error != null && controller.records.isEmpty) { if (controller.error != null && controller.records.isEmpty) {
return _refreshable( return _refreshableState(
children: [ child: _FailureState(
SizedBox( message: errorText(l10n, controller.error, l10n.progressError),
height: _emptyListHeight, onRetry: controller.load,
child: _FailureState( ),
message: errorText(l10n, controller.error, l10n.progressError),
onRetry: controller.load,
),
),
],
); );
} }
if (controller.records.isEmpty) { if (controller.records.isEmpty) {
return _refreshable( return _refreshableState(
children: [ child: EmptyState(
SizedBox( key: const Key('watch-records-empty'),
height: _emptyListHeight, icon: Icons.history_rounded,
child: EmptyState( title: l10n.noWatchRecords,
key: const Key('watch-records-empty'), subtitle: l10n.emptyHint,
icon: Icons.history_rounded, stretchContent: true,
title: l10n.noWatchRecords, ),
subtitle: l10n.emptyHint,
),
),
],
); );
} }
return _refreshable( 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) { bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false; if (notification.depth != 0) return false;
final reachedBottom = final reachedBottom =
@@ -303,6 +304,7 @@ class _FailureState extends StatelessWidget {
actionLabel: l10n.retry, actionLabel: l10n.retry,
actionKey: const Key('watch-records-retry'), actionKey: const Key('watch-records-retry'),
onAction: () => unawaited(onRetry()), onAction: () => unawaited(onRetry()),
stretchContent: true,
); );
} }
} }
+3 -3
View File
@@ -69,7 +69,7 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets('should_render_compact_dimensions_for_embedded_empty_state', ( testWidgets('should_keep_standard_icon_size_in_compact_empty_state', (
tester, tester,
) async { ) async {
await _pump( await _pump(
@@ -83,7 +83,7 @@ void main() {
expect( expect(
tester.getSize(find.byKey(const Key('empty-state-icon'))), tester.getSize(find.byKey(const Key('empty-state-icon'))),
const Size(44, 44), const Size(72, 72),
); );
expect(find.byType(DecoratedBox), findsNothing); expect(find.byType(DecoratedBox), findsNothing);
expect( expect(
@@ -97,7 +97,7 @@ void main() {
tester tester
.widget<Padding>(find.byKey(const Key('empty-state-padding'))) .widget<Padding>(find.byKey(const Key('empty-state-padding')))
.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); expect(find.byKey(const Key('empty-state-action')), findsNothing);
}); });
+8
View File
@@ -174,6 +174,14 @@ void main() {
expect(find.byKey(const Key('favorites-empty')), findsOneWidget); expect(find.byKey(const Key('favorites-empty')), findsOneWidget);
expect(find.byType(EmptyState), 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 { testWidgets('should_show_retry_when_favorite_list_fails', (tester) async {
+21
View File
@@ -209,6 +209,27 @@ void main() {
expect(find.text('The Signal'), findsOneWidget); 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', ( testWidgets('should_remove_and_clear_device_history_when_signed_out', (
tester, tester,
) async { ) async {