feat(flutter): 统一空列表占位组件

This commit is contained in:
Zhou Weidong committed 2026-10-17 17:44:44 +08:00
1 parent 19babb8f4a
commit 6d08330388
24 files changed
+394 -63

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
const double _regularArtSize = 96;
const double _compactArtSize = 64;
const double _regularIconSize = 42;
const double _compactIconSize = 28;
const double _regularRadius = 28;
const double _compactRadius = 18;
const double _maxContentWidth = 280;
const double _actionMinHeight = 44;
const double _artOpacity = 0.11;
/// 统一空列表占位:柔和图标、标题、可选说明与操作。
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
this.compact = false,
});
final IconData icon;
final String title;
final String? subtitle;
final String? actionLabel;
final VoidCallback? onAction;
final bool compact;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final dark = Theme.of(context).brightness == Brightness.dark;
final iconColor = dark ? AppColors.blueSoft : AppColors.blue;
final artSize = compact ? _compactArtSize : _regularArtSize;
final iconSize = compact ? _compactIconSize : _regularIconSize;
final hasAction = actionLabel != null && onAction != null;
return Center(
child: ConstrainedBox(
key: const Key('empty-state-content'),
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
child: Padding(
padding: EdgeInsets.all(compact ? 16 : 24),
child: Semantics(
container: true,
label: title,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ExcludeSemantics(
child: Container(
key: const Key('empty-state-art'),
width: artSize,
height: artSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: _artOpacity),
borderRadius: BorderRadius.circular(
compact ? _compactRadius : _regularRadius,
),
),
child: Icon(
icon,
key: const Key('empty-state-icon'),
size: iconSize,
color: iconColor,
),
),
),
SizedBox(height: compact ? 10 : 16),
ExcludeSemantics(
child: Text(
title,
key: const Key('empty-state-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.onSurface,
fontSize: compact ? 14 : 16,
fontWeight: FontWeight.w600,
height: 1.35,
),
),
),
if (subtitle != null) ...[
const SizedBox(height: 6),
ExcludeSemantics(
child: Text(
subtitle!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.muted,
fontSize: 13,
height: 1.4,
),
),
),
],
if (hasAction) ...[
const SizedBox(height: 10),
TextButton(
key: const Key('empty-state-action'),
onPressed: onAction,
style: TextButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(44, _actionMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 14),
),
child: Text(actionLabel!),
),
],
],
),
),
),
),
);
}
}
+1
View File
@@ -28,6 +28,7 @@
"videoPending": "Video pending",
"watchNow": "Watch free",
"noSearchResults": "No matching dramas.",
"emptyHint": "New content will appear here.",
"demoCatalogNotice": "Three demo dramas share the same local video.",
"myTab": "My",
"localViewer": "Local Viewer",
+1
View File
@@ -28,6 +28,7 @@
"videoPending": "Video belum siap",
"watchNow": "Tonton gratis",
"noSearchResults": "Tidak ada drama yang cocok.",
"emptyHint": "Konten baru akan muncul di sini.",
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
"myTab": "Saya",
"localViewer": "Penonton Lokal",
+6
View File
@@ -268,6 +268,12 @@ abstract class AppLocalizations {
/// **'No matching dramas.'**
String get noSearchResults;
/// No description provided for @emptyHint.
///
/// In en, this message translates to:
/// **'New content will appear here.'**
String get emptyHint;
/// No description provided for @demoCatalogNotice.
///
/// In en, this message translates to:
+3
View File
@@ -94,6 +94,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noSearchResults => 'No matching dramas.';
@override
String get emptyHint => 'New content will appear here.';
@override
String get demoCatalogNotice =>
'Three demo dramas share the same local video.';
+3
View File
@@ -94,6 +94,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get noSearchResults => 'Tidak ada drama yang cocok.';
@override
String get emptyHint => 'Konten baru akan muncul di sini.';
@override
String get demoCatalogNotice =>
'Tiga drama demo memakai video lokal yang sama.';
+3
View File
@@ -92,6 +92,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noSearchResults => '没有匹配的短剧。';
@override
String get emptyHint => '有新内容时会显示在这里。';
@override
String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。';
+1
View File
@@ -28,6 +28,7 @@
"videoPending": "视频待提供",
"watchNow": "免费观看",
"noSearchResults": "没有匹配的短剧。",
"emptyHint": "有新内容时会显示在这里。",
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
"myTab": "我的",
"localViewer": "本地观众",
+1
View File
@@ -11,6 +11,7 @@ import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
@@ -44,14 +44,19 @@ class _HomeDramaGrid extends StatelessWidget {
final dramas = categories.visibleDramas;
if (dramas.isEmpty) {
final missingCategory = categories.selectedSectionMissing;
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 24),
sliver: SliverToBoxAdapter(
child: Text(
categories.selectedSectionMissing
child: EmptyState(
key: const Key('home-all-dramas-empty'),
icon: missingCategory
? Icons.grid_view_rounded
: Icons.search_off_rounded,
title: missingCategory
? l10n.noCategoryDramas
: l10n.noSearchResults,
style: const TextStyle(color: AppColors.muted),
subtitle: l10n.emptyHint,
),
),
);
+5 -7
View File
@@ -15,6 +15,7 @@ import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import 'downloads_binding.dart';
@@ -144,14 +145,11 @@ class _DownloadsBody extends StatelessWidget {
],
),
if (downloads.licenses.isEmpty)
Padding(
EmptyState(
key: const Key('downloads-empty'),
padding: const EdgeInsets.symmetric(vertical: 34),
child: Text(
l10n.noDownloads,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
icon: Icons.download_outlined,
title: l10n.noDownloads,
subtitle: l10n.emptyHint,
)
else
for (final license in downloads.licenses) ...[
+8 -22
View File
@@ -17,6 +17,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart';
import 'drama_detail_binding.dart';
@@ -114,28 +115,13 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
foregroundColor: Colors.white,
title: Text(localizedDramaTitle(l10n, drama)),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: 12),
Text(
l10n.noEpisodesYet,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: Text(l10n.retry)),
],
),
),
body: EmptyState(
key: const Key('drama-detail-empty'),
icon: Icons.video_library_outlined,
title: l10n.noEpisodesYet,
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: _load,
),
);
}
+5 -5
View File
@@ -12,6 +12,7 @@ import '../../core/formatting/error_text.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/library_drama_tile.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
@@ -101,12 +102,11 @@ class _FavoritesBody extends StatelessWidget {
);
}
if (favorites.cards.isEmpty) {
return Center(
return EmptyState(
key: const Key('favorites-empty'),
child: Text(
l10n.noFavorites,
style: const TextStyle(color: Colors.white70),
),
icon: Icons.favorite_border_rounded,
title: l10n.noFavorites,
subtitle: l10n.emptyHint,
);
}
return RefreshIndicator(
+22 -2
View File
@@ -14,6 +14,7 @@ import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart';
import '../mine/mine_view.dart';
@@ -46,14 +47,20 @@ class HomePage extends StatelessWidget {
body: Center(child: CircularProgressIndicator()),
);
}
if (catalog.error != null || catalog.dramas.isEmpty) {
if (catalog.error != null) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
size: 40,
),
const SizedBox(height: 12),
Text(l10n.catalogError),
const SizedBox(height: 12),
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
if (kDebugMode) ...[
@@ -68,6 +75,19 @@ class HomePage extends StatelessWidget {
),
);
}
if (catalog.dramas.isEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: EmptyState(
key: const Key('home-empty'),
icon: Icons.movie_outlined,
title: l10n.noStories,
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: catalog.load,
),
);
}
return _HomeTabs(
key: ValueKey(catalog.dramas.first.id),
+11 -9
View File
@@ -12,6 +12,7 @@ import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
@@ -670,12 +671,11 @@ class _MyPageState extends State<MyPage> {
const SizedBox(height: 9),
]
else
Padding(
padding: const EdgeInsets.symmetric(vertical: 22),
child: Text(
l10n.noSavedShows,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
EmptyState(
key: const Key('mine-saved-empty'),
icon: Icons.bookmark_border_rounded,
title: l10n.noSavedShows,
subtitle: l10n.emptyHint,
),
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
@@ -732,9 +732,11 @@ class _MyPageState extends State<MyPage> {
],
)
else if (_recentDramas.isEmpty)
Text(
l10n.noStories,
style: const TextStyle(color: Color(0xFFB7B3B8)),
EmptyState(
key: const Key('mine-recent-empty'),
icon: Icons.history_rounded,
title: l10n.noStories,
subtitle: l10n.emptyHint,
)
else
for (final drama in _recentDramas) ...[
+5 -5
View File
@@ -12,6 +12,7 @@ import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_date_time.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
/// Anonymous system notice list with pagination and detail dialog.
@@ -109,12 +110,11 @@ class _NoticesPageState extends State<NoticesPage> {
children: [
SizedBox(
height: MediaQuery.sizeOf(context).height * 0.7,
child: Center(
child: EmptyState(
key: const Key('notices-empty'),
child: Text(
l10n.noNotices,
style: const TextStyle(color: AppColors.muted),
),
icon: Icons.notifications_none_rounded,
title: l10n.noNotices,
subtitle: l10n.emptyHint,
),
),
],
@@ -14,6 +14,7 @@ import '../../../core/formatting/error_text.dart';
import '../../../core/platform/haptics.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/auth_gate.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import '../../interaction/widgets/report_dialog.dart';
import 'comment_tile.dart';
@@ -219,12 +220,11 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
);
}
if (_controller.comments.isEmpty) {
return Center(
return EmptyState(
key: const Key('comment-list-empty'),
child: Text(
l10n.noComments,
style: const TextStyle(color: AppColors.muted),
),
icon: Icons.chat_bubble_outline_rounded,
title: l10n.noComments,
compact: true,
);
}
return ListView.builder(
@@ -17,6 +17,7 @@ import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
@@ -92,11 +93,11 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
);
}
if (controller.records.isEmpty) {
return Center(
child: Text(
l10n.noWatchRecords,
style: const TextStyle(color: Colors.white70),
),
return EmptyState(
key: const Key('watch-records-empty'),
icon: Icons.history_rounded,
title: l10n.noWatchRecords,
subtitle: l10n.emptyHint,
);
}
return Column(
@@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/widgets/empty_state.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart';
import 'package:provider/provider.dart';
@@ -68,6 +69,19 @@ void main() {
expect(repository.pageCalls.last.cursor, '10');
});
testWidgets('should_show_compact_empty_state_when_no_comments_exist', (
tester,
) async {
final repository = FakeCommentRepository(pages: [const CommentPage()]);
await _pumpSheet(tester, repository: repository);
expect(find.byKey(const Key('comment-list-empty')), findsOneWidget);
expect(find.byType(EmptyState), findsOneWidget);
expect(tester.widget<EmptyState>(find.byType(EmptyState)).compact, isTrue);
expect(find.text('No comments yet.'), findsOneWidget);
});
testWidgets(
'should_keep_input_and_show_backend_message_when_comment_is_blocked',
(tester) async {
+3
View File
@@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/widgets/empty_state.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/downloads/downloads_page.dart';
import 'package:provider/provider.dart';
@@ -90,6 +91,7 @@ void main() {
await _pumpPage(tester, auth: auth, controller: controller);
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
expect(find.byType(EmptyState), findsOneWidget);
});
testWidgets('should_show_retry_when_policy_or_list_load_fails', (
@@ -163,6 +165,7 @@ void main() {
expect(repository.clearCalls, 1);
expect(haptics.mediumImpacts, 1);
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
expect(find.byType(EmptyState), findsOneWidget);
});
testWidgets(
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/core/widgets/empty_state.dart';
void main() {
testWidgets('should_render_title_subtitle_action_and_title_only_semantics', (
tester,
) async {
var taps = 0;
await _pump(
tester,
theme: AppTheme.dark(),
child: EmptyState(
icon: Icons.movie_outlined,
title: 'No stories',
subtitle: 'New content will appear here.',
actionLabel: 'Retry',
onAction: () => taps++,
),
);
expect(find.byKey(const Key('empty-state-art')), findsOneWidget);
expect(find.byKey(const Key('empty-state-icon')), findsOneWidget);
expect(find.text('No stories'), findsOneWidget);
expect(find.text('New content will appear here.'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('empty-state-art'))),
const Size(96, 96),
);
expect(
tester.getSize(find.byKey(const Key('empty-state-icon'))),
const Size(42, 42),
);
expect(
tester
.widget<Text>(find.byKey(const Key('empty-state-title')))
.style
?.fontSize,
16,
);
expect(
tester.getSize(find.byKey(const Key('empty-state-action'))).height,
greaterThanOrEqualTo(44),
);
final semantics = tester.ensureSemantics();
expect(find.bySemanticsLabel('No stories'), findsOneWidget);
expect(
find.bySemanticsLabel('New content will appear here.'),
findsNothing,
);
await tester.tap(find.byKey(const Key('empty-state-action')));
expect(taps, 1);
semantics.dispose();
});
testWidgets('should_render_compact_dimensions_for_embedded_empty_state', (
tester,
) async {
await _pump(
tester,
child: const EmptyState(
icon: Icons.chat_bubble_outline_rounded,
title: 'No comments yet.',
compact: true,
),
);
expect(
tester.getSize(find.byKey(const Key('empty-state-art'))),
const Size(64, 64),
);
expect(
tester.getSize(find.byKey(const Key('empty-state-icon'))),
const Size(28, 28),
);
expect(
tester
.widget<Text>(find.byKey(const Key('empty-state-title')))
.style
?.fontSize,
14,
);
expect(find.byKey(const Key('empty-state-action')), findsNothing);
});
testWidgets('should_use_theme_aware_icon_colors', (tester) async {
await _pump(
tester,
theme: AppTheme.dark(),
child: const EmptyState(icon: Icons.history_rounded, title: 'No history'),
);
expect(
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
AppColors.blueSoft,
);
await _pump(
tester,
theme: AppTheme.light(),
child: const EmptyState(icon: Icons.history_rounded, title: 'No history'),
);
expect(
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
AppColors.blue,
);
});
testWidgets('should_fit_compact_width_without_overflow', (tester) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pump(
tester,
child: const EmptyState(
icon: Icons.download_outlined,
title: 'No download authorizations yet.',
subtitle: 'New content will appear here.',
actionLabel: 'Retry',
),
);
final content = tester.widget<ConstrainedBox>(
find.byKey(const Key('empty-state-content')),
);
expect(content.constraints.maxWidth, 280);
expect(tester.takeException(), isNull);
});
}
Future<void> _pump(
WidgetTester tester, {
ThemeData? theme,
required Widget child,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: theme ?? AppTheme.dark(),
home: Scaffold(body: child),
),
);
await tester.pumpAndSettle();
}
+2
View File
@@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/widgets/empty_state.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/favorites/favorites_page.dart';
import 'package:provider/provider.dart';
@@ -83,6 +84,7 @@ void main() {
await _pumpPage(tester, auth: auth, favorites: favorites);
expect(find.byKey(const Key('favorites-empty')), findsOneWidget);
expect(find.byType(EmptyState), findsOneWidget);
});
testWidgets('should_show_retry_when_favorite_list_fails', (tester) async {
+2
View File
@@ -6,6 +6,7 @@ import 'package:fruitshorts/core/data/models/notice.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
import 'package:fruitshorts/core/formatting/localized_date_time.dart';
import 'package:fruitshorts/core/widgets/empty_state.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/notices/notices_page.dart';
import 'package:provider/provider.dart';
@@ -39,6 +40,7 @@ void main() {
);
expect(find.byKey(const Key('notices-empty')), findsOneWidget);
expect(find.byType(EmptyState), findsOneWidget);
});
testWidgets('should_show_backend_error_and_retry_when_page_fails', (
+2
View File
@@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/widgets/empty_state.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/watch_records/watch_records_page.dart';
import 'package:provider/provider.dart';
@@ -124,6 +125,7 @@ void main() {
expect(history.clearCalls, 1);
expect(haptics.mediumImpacts, 1);
expect(find.text('No watch history yet.'), findsOneWidget);
expect(find.byType(EmptyState), findsOneWidget);
},
);