refactor(empty-state): 空状态支持内容居中
This commit is contained in:
1 parent
59fbd33059
commit
7463d3ee26
8 files changed
+92
-41
No files matched your search
@@ -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,49 +52,53 @@ 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: SizedBox(
|
||||||
|
width: stretchContent ? double.infinity : null,
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
container: true,
|
container: true,
|
||||||
label: title,
|
label: title,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ExcludeSemantics(
|
ExcludeSemantics(child: _EmptyMark(icon: icon)),
|
||||||
child: _EmptyMark(icon: icon, compact: compact),
|
|
||||||
),
|
|
||||||
SizedBox(height: compact ? _compactGap : _regularGap),
|
SizedBox(height: compact ? _compactGap : _regularGap),
|
||||||
_EmptyCopy(
|
SizedBox(
|
||||||
|
width: stretchContent ? double.infinity : null,
|
||||||
|
child: _EmptyCopy(
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
actionLabel: hasAction ? actionLabel : null,
|
actionLabel: hasAction ? actionLabel : null,
|
||||||
actionKey: actionKey,
|
actionKey: actionKey,
|
||||||
onAction: hasAction ? onAction : null,
|
onAction: hasAction ? onAction : null,
|
||||||
footer: footer,
|
footer: footer,
|
||||||
|
stretchContent: stretchContent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 空列表只放一枚大图标,不画线框,也不铺底色。
|
/// 空列表只放一枚大图标,不画线框,也不铺底色。
|
||||||
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,11 +123,8 @@ 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: [
|
|
||||||
SizedBox(
|
|
||||||
height: _emptyListHeight,
|
|
||||||
child: EmptyState(
|
child: EmptyState(
|
||||||
key: const Key('favorites-error'),
|
key: const Key('favorites-error'),
|
||||||
icon: Icons.cloud_off_rounded,
|
icon: Icons.cloud_off_rounded,
|
||||||
@@ -136,25 +132,20 @@ class _FavoritesBody extends StatelessWidget {
|
|||||||
actionLabel: l10n.retry,
|
actionLabel: l10n.retry,
|
||||||
actionKey: const Key('favorites-retry'),
|
actionKey: const Key('favorites-retry'),
|
||||||
onAction: () => unawaited(favorites.load()),
|
onAction: () => unawaited(favorites.load()),
|
||||||
|
stretchContent: true,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (favorites.cards.isEmpty) {
|
if (favorites.cards.isEmpty) {
|
||||||
return _refreshable(
|
return _refreshableState(
|
||||||
favorites,
|
favorites,
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
height: _emptyListHeight,
|
|
||||||
child: EmptyState(
|
child: EmptyState(
|
||||||
key: const Key('favorites-empty'),
|
key: const Key('favorites-empty'),
|
||||||
icon: Icons.star_border_rounded,
|
icon: Icons.star_border_rounded,
|
||||||
title: l10n.noFavorites,
|
title: l10n.noFavorites,
|
||||||
subtitle: l10n.emptyHint,
|
subtitle: l10n.emptyHint,
|
||||||
|
stretchContent: true,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
|
||||||
SizedBox(
|
|
||||||
height: _emptyListHeight,
|
|
||||||
child: _FailureState(
|
child: _FailureState(
|
||||||
message: errorText(l10n, controller.error, l10n.progressError),
|
message: errorText(l10n, controller.error, l10n.progressError),
|
||||||
onRetry: controller.load,
|
onRetry: controller.load,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (controller.records.isEmpty) {
|
if (controller.records.isEmpty) {
|
||||||
return _refreshable(
|
return _refreshableState(
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
height: _emptyListHeight,
|
|
||||||
child: EmptyState(
|
child: EmptyState(
|
||||||
key: const Key('watch-records-empty'),
|
key: const Key('watch-records-empty'),
|
||||||
icon: Icons.history_rounded,
|
icon: Icons.history_rounded,
|
||||||
title: l10n.noWatchRecords,
|
title: l10n.noWatchRecords,
|
||||||
subtitle: l10n.emptyHint,
|
subtitle: l10n.emptyHint,
|
||||||
|
stretchContent: true,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user