feat(flutter): 统一空列表占位组件
This commit is contained in:
1 parent
19babb8f4a
commit
6d08330388
24 files changed
+394
-63
No files matched your search
@@ -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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -92,6 +92,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get noSearchResults => '没有匹配的短剧。';
|
||||
|
||||
@override
|
||||
String get emptyHint => '有新内容时会显示在这里。';
|
||||
|
||||
@override
|
||||
String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。';
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"videoPending": "视频待提供",
|
||||
"watchNow": "免费观看",
|
||||
"noSearchResults": "没有匹配的短剧。",
|
||||
"emptyHint": "有新内容时会显示在这里。",
|
||||
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
|
||||
"myTab": "我的",
|
||||
"localViewer": "本地观众",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user