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",
|
"videoPending": "Video pending",
|
||||||
"watchNow": "Watch free",
|
"watchNow": "Watch free",
|
||||||
"noSearchResults": "No matching dramas.",
|
"noSearchResults": "No matching dramas.",
|
||||||
|
"emptyHint": "New content will appear here.",
|
||||||
"demoCatalogNotice": "Three demo dramas share the same local video.",
|
"demoCatalogNotice": "Three demo dramas share the same local video.",
|
||||||
"myTab": "My",
|
"myTab": "My",
|
||||||
"localViewer": "Local Viewer",
|
"localViewer": "Local Viewer",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"videoPending": "Video belum siap",
|
"videoPending": "Video belum siap",
|
||||||
"watchNow": "Tonton gratis",
|
"watchNow": "Tonton gratis",
|
||||||
"noSearchResults": "Tidak ada drama yang cocok.",
|
"noSearchResults": "Tidak ada drama yang cocok.",
|
||||||
|
"emptyHint": "Konten baru akan muncul di sini.",
|
||||||
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
|
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
|
||||||
"myTab": "Saya",
|
"myTab": "Saya",
|
||||||
"localViewer": "Penonton Lokal",
|
"localViewer": "Penonton Lokal",
|
||||||
|
|||||||
@@ -268,6 +268,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'No matching dramas.'**
|
/// **'No matching dramas.'**
|
||||||
String get noSearchResults;
|
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.
|
/// No description provided for @demoCatalogNotice.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSearchResults => 'No matching dramas.';
|
String get noSearchResults => 'No matching dramas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyHint => 'New content will appear here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get demoCatalogNotice =>
|
String get demoCatalogNotice =>
|
||||||
'Three demo dramas share the same local video.';
|
'Three demo dramas share the same local video.';
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSearchResults => 'Tidak ada drama yang cocok.';
|
String get noSearchResults => 'Tidak ada drama yang cocok.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyHint => 'Konten baru akan muncul di sini.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get demoCatalogNotice =>
|
String get demoCatalogNotice =>
|
||||||
'Tiga drama demo memakai video lokal yang sama.';
|
'Tiga drama demo memakai video lokal yang sama.';
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noSearchResults => '没有匹配的短剧。';
|
String get noSearchResults => '没有匹配的短剧。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyHint => '有新内容时会显示在这里。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。';
|
String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。';
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"videoPending": "视频待提供",
|
"videoPending": "视频待提供",
|
||||||
"watchNow": "免费观看",
|
"watchNow": "免费观看",
|
||||||
"noSearchResults": "没有匹配的短剧。",
|
"noSearchResults": "没有匹配的短剧。",
|
||||||
|
"emptyHint": "有新内容时会显示在这里。",
|
||||||
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
|
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
|
||||||
"myTab": "我的",
|
"myTab": "我的",
|
||||||
"localViewer": "本地观众",
|
"localViewer": "本地观众",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../core/data/services/catalog_controller.dart';
|
|||||||
import '../../core/data/services/category_controller.dart';
|
import '../../core/data/services/category_controller.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'discover_binding.dart';
|
import 'discover_binding.dart';
|
||||||
|
|||||||
@@ -44,14 +44,19 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
|
|
||||||
final dramas = categories.visibleDramas;
|
final dramas = categories.visibleDramas;
|
||||||
if (dramas.isEmpty) {
|
if (dramas.isEmpty) {
|
||||||
|
final missingCategory = categories.selectedSectionMissing;
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 24),
|
||||||
sliver: SliverToBoxAdapter(
|
sliver: SliverToBoxAdapter(
|
||||||
child: Text(
|
child: EmptyState(
|
||||||
categories.selectedSectionMissing
|
key: const Key('home-all-dramas-empty'),
|
||||||
|
icon: missingCategory
|
||||||
|
? Icons.grid_view_rounded
|
||||||
|
: Icons.search_off_rounded,
|
||||||
|
title: missingCategory
|
||||||
? l10n.noCategoryDramas
|
? l10n.noCategoryDramas
|
||||||
: l10n.noSearchResults,
|
: 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_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'downloads_binding.dart';
|
import 'downloads_binding.dart';
|
||||||
|
|
||||||
@@ -144,14 +145,11 @@ class _DownloadsBody extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (downloads.licenses.isEmpty)
|
if (downloads.licenses.isEmpty)
|
||||||
Padding(
|
EmptyState(
|
||||||
key: const Key('downloads-empty'),
|
key: const Key('downloads-empty'),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 34),
|
icon: Icons.download_outlined,
|
||||||
child: Text(
|
title: l10n.noDownloads,
|
||||||
l10n.noDownloads,
|
subtitle: l10n.emptyHint,
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
for (final license in downloads.licenses) ...[
|
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/remote_image.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../core/widgets/download_action.dart';
|
import '../../core/widgets/download_action.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../interaction/widgets/report_dialog.dart';
|
import '../interaction/widgets/report_dialog.dart';
|
||||||
import 'drama_detail_binding.dart';
|
import 'drama_detail_binding.dart';
|
||||||
@@ -114,28 +115,13 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
title: Text(localizedDramaTitle(l10n, drama)),
|
title: Text(localizedDramaTitle(l10n, drama)),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: EmptyState(
|
||||||
child: Padding(
|
key: const Key('drama-detail-empty'),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
icon: Icons.video_library_outlined,
|
||||||
child: Column(
|
title: l10n.noEpisodesYet,
|
||||||
mainAxisSize: MainAxisSize.min,
|
subtitle: l10n.emptyHint,
|
||||||
children: [
|
actionLabel: l10n.retry,
|
||||||
const Icon(
|
onAction: _load,
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../core/formatting/error_text.dart';
|
|||||||
import '../../core/platform/haptics.dart';
|
import '../../core/platform/haptics.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/library_drama_tile.dart';
|
import '../../core/widgets/library_drama_tile.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../drama_detail/drama_detail_view.dart';
|
import '../drama_detail/drama_detail_view.dart';
|
||||||
@@ -101,12 +102,11 @@ class _FavoritesBody extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (favorites.cards.isEmpty) {
|
if (favorites.cards.isEmpty) {
|
||||||
return Center(
|
return EmptyState(
|
||||||
key: const Key('favorites-empty'),
|
key: const Key('favorites-empty'),
|
||||||
child: Text(
|
icon: Icons.favorite_border_rounded,
|
||||||
l10n.noFavorites,
|
title: l10n.noFavorites,
|
||||||
style: const TextStyle(color: Colors.white70),
|
subtitle: l10n.emptyHint,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../core/platform/haptics.dart';
|
|||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../discover/discover_view.dart';
|
import '../discover/discover_view.dart';
|
||||||
import '../mine/mine_view.dart';
|
import '../mine/mine_view.dart';
|
||||||
@@ -46,14 +47,20 @@ class HomePage extends StatelessWidget {
|
|||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (catalog.error != null || catalog.dramas.isEmpty) {
|
if (catalog.error != null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(height: 12),
|
||||||
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
|
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
|
||||||
if (kDebugMode) ...[
|
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(
|
return _HomeTabs(
|
||||||
key: ValueKey(catalog.dramas.first.id),
|
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/interaction_controller.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
import '../../core/formatting/error_text.dart';
|
import '../../core/formatting/error_text.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
@@ -670,12 +671,11 @@ class _MyPageState extends State<MyPage> {
|
|||||||
const SizedBox(height: 9),
|
const SizedBox(height: 9),
|
||||||
]
|
]
|
||||||
else
|
else
|
||||||
Padding(
|
EmptyState(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 22),
|
key: const Key('mine-saved-empty'),
|
||||||
child: Text(
|
icon: Icons.bookmark_border_rounded,
|
||||||
l10n.noSavedShows,
|
title: l10n.noSavedShows,
|
||||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
subtitle: l10n.emptyHint,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
|
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
|
||||||
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
|
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
|
||||||
@@ -732,9 +732,11 @@ class _MyPageState extends State<MyPage> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
else if (_recentDramas.isEmpty)
|
else if (_recentDramas.isEmpty)
|
||||||
Text(
|
EmptyState(
|
||||||
l10n.noStories,
|
key: const Key('mine-recent-empty'),
|
||||||
style: const TextStyle(color: Color(0xFFB7B3B8)),
|
icon: Icons.history_rounded,
|
||||||
|
title: l10n.noStories,
|
||||||
|
subtitle: l10n.emptyHint,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
for (final drama in _recentDramas) ...[
|
for (final drama in _recentDramas) ...[
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../core/formatting/error_text.dart';
|
|||||||
import '../../core/formatting/localized_date_time.dart';
|
import '../../core/formatting/localized_date_time.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Anonymous system notice list with pagination and detail dialog.
|
/// Anonymous system notice list with pagination and detail dialog.
|
||||||
@@ -109,12 +110,11 @@ class _NoticesPageState extends State<NoticesPage> {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: MediaQuery.sizeOf(context).height * 0.7,
|
height: MediaQuery.sizeOf(context).height * 0.7,
|
||||||
child: Center(
|
child: EmptyState(
|
||||||
key: const Key('notices-empty'),
|
key: const Key('notices-empty'),
|
||||||
child: Text(
|
icon: Icons.notifications_none_rounded,
|
||||||
l10n.noNotices,
|
title: l10n.noNotices,
|
||||||
style: const TextStyle(color: AppColors.muted),
|
subtitle: l10n.emptyHint,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../../core/formatting/error_text.dart';
|
|||||||
import '../../../core/platform/haptics.dart';
|
import '../../../core/platform/haptics.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/auth_gate.dart';
|
import '../../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../../core/widgets/empty_state.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../interaction/widgets/report_dialog.dart';
|
import '../../interaction/widgets/report_dialog.dart';
|
||||||
import 'comment_tile.dart';
|
import 'comment_tile.dart';
|
||||||
@@ -219,12 +220,11 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_controller.comments.isEmpty) {
|
if (_controller.comments.isEmpty) {
|
||||||
return Center(
|
return EmptyState(
|
||||||
key: const Key('comment-list-empty'),
|
key: const Key('comment-list-empty'),
|
||||||
child: Text(
|
icon: Icons.chat_bubble_outline_rounded,
|
||||||
l10n.noComments,
|
title: l10n.noComments,
|
||||||
style: const TextStyle(color: AppColors.muted),
|
compact: true,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../../core/platform/haptics.dart';
|
|||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../drama_detail/drama_detail_view.dart';
|
import '../drama_detail/drama_detail_view.dart';
|
||||||
@@ -92,11 +93,11 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (controller.records.isEmpty) {
|
if (controller.records.isEmpty) {
|
||||||
return Center(
|
return EmptyState(
|
||||||
child: Text(
|
key: const Key('watch-records-empty'),
|
||||||
l10n.noWatchRecords,
|
icon: Icons.history_rounded,
|
||||||
style: const TextStyle(color: Colors.white70),
|
title: l10n.noWatchRecords,
|
||||||
),
|
subtitle: l10n.emptyHint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Column(
|
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_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||||
import 'package:fruitshorts/core/platform/haptics.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/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart';
|
import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -68,6 +69,19 @@ void main() {
|
|||||||
expect(repository.pageCalls.last.cursor, '10');
|
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(
|
testWidgets(
|
||||||
'should_keep_input_and_show_backend_message_when_comment_is_blocked',
|
'should_keep_input_and_show_backend_message_when_comment_is_blocked',
|
||||||
(tester) async {
|
(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/auth_session_store.dart';
|
||||||
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
||||||
import 'package:fruitshorts/core/platform/haptics.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/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/downloads/downloads_page.dart';
|
import 'package:fruitshorts/pages/downloads/downloads_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -90,6 +91,7 @@ void main() {
|
|||||||
await _pumpPage(tester, auth: auth, controller: controller);
|
await _pumpPage(tester, auth: auth, controller: controller);
|
||||||
|
|
||||||
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
|
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
|
||||||
|
expect(find.byType(EmptyState), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_show_retry_when_policy_or_list_load_fails', (
|
testWidgets('should_show_retry_when_policy_or_list_load_fails', (
|
||||||
@@ -163,6 +165,7 @@ void main() {
|
|||||||
expect(repository.clearCalls, 1);
|
expect(repository.clearCalls, 1);
|
||||||
expect(haptics.mediumImpacts, 1);
|
expect(haptics.mediumImpacts, 1);
|
||||||
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
|
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
|
||||||
|
expect(find.byType(EmptyState), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
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/auth_session_store.dart';
|
||||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||||
import 'package:fruitshorts/core/platform/haptics.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/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/favorites/favorites_page.dart';
|
import 'package:fruitshorts/pages/favorites/favorites_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -83,6 +84,7 @@ void main() {
|
|||||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||||
|
|
||||||
expect(find.byKey(const Key('favorites-empty')), findsOneWidget);
|
expect(find.byKey(const Key('favorites-empty')), findsOneWidget);
|
||||||
|
expect(find.byType(EmptyState), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_show_retry_when_favorite_list_fails', (tester) async {
|
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/remote/api_client.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
|
||||||
import 'package:fruitshorts/core/formatting/localized_date_time.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/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/notices/notices_page.dart';
|
import 'package:fruitshorts/pages/notices/notices_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -39,6 +40,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(find.byKey(const Key('notices-empty')), findsOneWidget);
|
expect(find.byKey(const Key('notices-empty')), findsOneWidget);
|
||||||
|
expect(find.byType(EmptyState), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_show_backend_error_and_retry_when_page_fails', (
|
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_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||||
import 'package:fruitshorts/core/platform/haptics.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/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/watch_records/watch_records_page.dart';
|
import 'package:fruitshorts/pages/watch_records/watch_records_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -124,6 +125,7 @@ void main() {
|
|||||||
expect(history.clearCalls, 1);
|
expect(history.clearCalls, 1);
|
||||||
expect(haptics.mediumImpacts, 1);
|
expect(haptics.mediumImpacts, 1);
|
||||||
expect(find.text('No watch history yet.'), findsOneWidget);
|
expect(find.text('No watch history yet.'), findsOneWidget);
|
||||||
|
expect(find.byType(EmptyState), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user