diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart new file mode 100644 index 0000000..b9b6093 --- /dev/null +++ b/lib/core/widgets/empty_state.dart @@ -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!), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e2dfb60..f94a454 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 55e5409..92ea241 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5b57f1c..4c04af7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 42cf09b..39f794a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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.'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 7e05221..fa6c474 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f0a608a..23a1192 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -92,6 +92,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get noSearchResults => '没有匹配的短剧。'; + @override + String get emptyHint => '有新内容时会显示在这里。'; + @override String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 71a6cb8..c3d1daa 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -28,6 +28,7 @@ "videoPending": "视频待提供", "watchNow": "免费观看", "noSearchResults": "没有匹配的短剧。", + "emptyHint": "有新内容时会显示在这里。", "demoCatalogNotice": "三部演示短剧共用同一条本地视频。", "myTab": "我的", "localViewer": "本地观众", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 5a1a97a..abf62df 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -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'; diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index de34d95..8ee80e3 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.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, ), ), ); diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index 4247658..3064507 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -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) ...[ diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index f52b8de..6dd8bd4 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -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 { 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, ), ); } diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 7033488..032695b 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -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( diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index e19b838..51c15a2 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -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), diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index c88d785..d0fe868 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -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 { 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 { ], ) 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) ...[ diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index e46ba02..7d2864b 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -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 { 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, ), ), ], diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 363321b..2926c2a 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -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 { ); } 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( diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 51c9f8f..130efc6 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -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( diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 3cadf2d..c2b2b21 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -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(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 { diff --git a/test/download/downloads_page_test.dart b/test/download/downloads_page_test.dart index 2866d09..af2c1de 100644 --- a/test/download/downloads_page_test.dart +++ b/test/download/downloads_page_test.dart @@ -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( diff --git a/test/empty_state/empty_state_test.dart b/test/empty_state/empty_state_test.dart new file mode 100644 index 0000000..aa6cdec --- /dev/null +++ b/test/empty_state/empty_state_test.dart @@ -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(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(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(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(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( + find.byKey(const Key('empty-state-content')), + ); + expect(content.constraints.maxWidth, 280); + expect(tester.takeException(), isNull); + }); +} + +Future _pump( + WidgetTester tester, { + ThemeData? theme, + required Widget child, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: theme ?? AppTheme.dark(), + home: Scaffold(body: child), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 8e2b0ff..3d12478 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -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 { diff --git a/test/notice/notices_page_test.dart b/test/notice/notices_page_test.dart index 7252b2e..8ec1a6b 100644 --- a/test/notice/notices_page_test.dart +++ b/test/notice/notices_page_test.dart @@ -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', ( diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 37330b6..d8c99e3 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -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); }, );