feat(flutter): 首页改为两列全部剧目网格
This commit is contained in:
1 parent
99f64ab870
commit
cf03178cc5
11 files changed
+498
-230
No files matched your search
@@ -55,6 +55,7 @@
|
|||||||
"animatedTab": "Animated",
|
"animatedTab": "Animated",
|
||||||
"recommendedTab": "Recommended",
|
"recommendedTab": "Recommended",
|
||||||
"noCategoryDramas": "No dramas in this category yet.",
|
"noCategoryDramas": "No dramas in this category yet.",
|
||||||
|
"allDramas": "All Dramas",
|
||||||
"trendingTab": "Trending",
|
"trendingTab": "Trending",
|
||||||
"liveActionTab": "Live Action",
|
"liveActionTab": "Live Action",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"animatedTab": "Animasi",
|
"animatedTab": "Animasi",
|
||||||
"recommendedTab": "Rekomendasi",
|
"recommendedTab": "Rekomendasi",
|
||||||
"noCategoryDramas": "Belum ada drama di kategori ini.",
|
"noCategoryDramas": "Belum ada drama di kategori ini.",
|
||||||
|
"allDramas": "Semua Drama",
|
||||||
"trendingTab": "Sedang Tren",
|
"trendingTab": "Sedang Tren",
|
||||||
"liveActionTab": "Live Action",
|
"liveActionTab": "Live Action",
|
||||||
"search": "Cari",
|
"search": "Cari",
|
||||||
|
|||||||
@@ -388,6 +388,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'No dramas in this category yet.'**
|
/// **'No dramas in this category yet.'**
|
||||||
String get noCategoryDramas;
|
String get noCategoryDramas;
|
||||||
|
|
||||||
|
/// No description provided for @allDramas.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All Dramas'**
|
||||||
|
String get allDramas;
|
||||||
|
|
||||||
/// No description provided for @trendingTab.
|
/// No description provided for @trendingTab.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCategoryDramas => 'No dramas in this category yet.';
|
String get noCategoryDramas => 'No dramas in this category yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allDramas => 'All Dramas';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trendingTab => 'Trending';
|
String get trendingTab => 'Trending';
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCategoryDramas => 'Belum ada drama di kategori ini.';
|
String get noCategoryDramas => 'Belum ada drama di kategori ini.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allDramas => 'Semua Drama';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trendingTab => 'Sedang Tren';
|
String get trendingTab => 'Sedang Tren';
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noCategoryDramas => '这个分类暂时没有剧集。';
|
String get noCategoryDramas => '这个分类暂时没有剧集。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allDramas => '全部剧目';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trendingTab => '热门';
|
String get trendingTab => '热门';
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"animatedTab": "漫剧",
|
"animatedTab": "漫剧",
|
||||||
"recommendedTab": "推荐",
|
"recommendedTab": "推荐",
|
||||||
"noCategoryDramas": "这个分类暂时没有剧集。",
|
"noCategoryDramas": "这个分类暂时没有剧集。",
|
||||||
|
"allDramas": "全部剧目",
|
||||||
"trendingTab": "热门",
|
"trendingTab": "热门",
|
||||||
"liveActionTab": "真人剧",
|
"liveActionTab": "真人剧",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import 'discover_binding.dart';
|
|||||||
import 'discover_logic.dart';
|
import 'discover_logic.dart';
|
||||||
import 'widgets/cover_carousel.dart';
|
import 'widgets/cover_carousel.dart';
|
||||||
|
|
||||||
|
part 'widgets/home_drama_grid.dart';
|
||||||
|
|
||||||
class DiscoverPage extends StatefulWidget {
|
class DiscoverPage extends StatefulWidget {
|
||||||
const DiscoverPage({
|
const DiscoverPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -101,40 +103,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
final categories = context.watch<CategoryController>();
|
final categories = context.watch<CategoryController>();
|
||||||
final homeBanners = context.watch<CatalogController>().feed.banners;
|
final homeBanners = context.watch<CatalogController>().feed.banners;
|
||||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||||
final cards = _cardsFor(categories.visibleDramas, '');
|
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_header(l10n),
|
_header(l10n),
|
||||||
Expanded(
|
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
|
||||||
child: ListView(
|
|
||||||
controller: logic.scroll,
|
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
child: _carousel(l10n, homeBanners, allDramas),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_categories(l10n, categories),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
_continueHeader(l10n),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_dramaContent(l10n, categories, cards),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!widget.embedded) _bottomNav(l10n),
|
if (!widget.embedded) _bottomNav(l10n),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -150,6 +124,60 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _feed(
|
||||||
|
AppLocalizations l10n,
|
||||||
|
CategoryController categories,
|
||||||
|
List<FeedBanner> banners,
|
||||||
|
List<Drama> allDramas,
|
||||||
|
) => CustomScrollView(
|
||||||
|
controller: logic.scroll,
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: _carousel(l10n, banners, allDramas),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 18, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_categories(l10n, categories),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
l10n.allDramas,
|
||||||
|
key: const Key('home-all-dramas-title'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_HomeDramaGrid(
|
||||||
|
l10n: l10n,
|
||||||
|
categories: categories,
|
||||||
|
onOpen: (drama) => logic.openCard(
|
||||||
|
context,
|
||||||
|
drama,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onDramaSelected: widget.onDramaSelected,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
Widget _header(AppLocalizations l10n) {
|
Widget _header(AppLocalizations l10n) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||||
@@ -239,164 +267,6 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _continueHeader(AppLocalizations l10n) => Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.continueWatching,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
l10n.seeAll,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.blueSoft,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _dramaContent(
|
|
||||||
AppLocalizations l10n,
|
|
||||||
CategoryController categories,
|
|
||||||
List<_ShowcaseCard> cards,
|
|
||||||
) {
|
|
||||||
if (categories.loading && categories.feed.sections.isEmpty) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
|
||||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (categories.error != null) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.categoriesError,
|
|
||||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(onPressed: categories.load, child: Text(l10n.retry)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (cards.isEmpty) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
|
||||||
child: Text(
|
|
||||||
categories.selectedSectionMissing
|
|
||||||
? l10n.noCategoryDramas
|
|
||||||
: l10n.noSearchResults,
|
|
||||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return SizedBox(
|
|
||||||
height: 196,
|
|
||||||
child: ListView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
children: [
|
|
||||||
for (final card in cards) ...[
|
|
||||||
_continueTile(l10n, card, categories.visibleDramas),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _continueTile(
|
|
||||||
AppLocalizations l10n,
|
|
||||||
_ShowcaseCard card,
|
|
||||||
List<Drama> source,
|
|
||||||
) {
|
|
||||||
final drama = logic.dramaFor(card.dramaId, source);
|
|
||||||
return SizedBox(
|
|
||||||
width: 132,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => logic.openCard(
|
|
||||||
context,
|
|
||||||
drama,
|
|
||||||
embedded: widget.embedded,
|
|
||||||
onDramaSelected: widget.onDramaSelected,
|
|
||||||
onAction: widget.onAction,
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
RemoteImage(card.image),
|
|
||||||
const DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0x00000000), Color(0xE6000000)],
|
|
||||||
stops: [0.45, 1],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (drama == null)
|
|
||||||
const ColoredBox(
|
|
||||||
color: Color(0x99000000),
|
|
||||||
child: Center(
|
|
||||||
child: Icon(Icons.image_outlined, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 10,
|
|
||||||
right: 10,
|
|
||||||
bottom: 10,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
card.title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
drama == null ? l10n.videoPending : card.genre,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xE6FFFFFF),
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
||||||
final choices = <({String code, String label})>[
|
final choices = <({String code, String label})>[
|
||||||
(code: CategoryController.allCode, label: l10n.forYou),
|
(code: CategoryController.allCode, label: l10n.forYou),
|
||||||
@@ -523,38 +393,6 @@ class _DiscoverNavItem extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ShowcaseCard {
|
|
||||||
const _ShowcaseCard({
|
|
||||||
required this.title,
|
|
||||||
required this.genre,
|
|
||||||
required this.image,
|
|
||||||
this.dramaId,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final String genre;
|
|
||||||
final String image;
|
|
||||||
final String? dramaId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
|
|
||||||
List<_ShowcaseCard> _cardsFor(List<Drama> dramas, String query) {
|
|
||||||
final normalized = query.trim().toLowerCase();
|
|
||||||
return [
|
|
||||||
for (final drama in dramas)
|
|
||||||
if (normalized.isEmpty ||
|
|
||||||
drama.title.toLowerCase().contains(normalized) ||
|
|
||||||
drama.genre.toLowerCase().contains(normalized) ||
|
|
||||||
drama.category.toLowerCase().contains(normalized))
|
|
||||||
_ShowcaseCard(
|
|
||||||
title: drama.title,
|
|
||||||
genre: drama.genre.isEmpty ? drama.category : drama.genre,
|
|
||||||
image: drama.posterUrl,
|
|
||||||
dramaId: drama.id,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const _accent = AppColors.blueSoft;
|
const _accent = AppColors.blueSoft;
|
||||||
const _coin = Color(0xFFF5C451);
|
const _coin = Color(0xFFF5C451);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
part of '../discover_view.dart';
|
||||||
|
|
||||||
|
class _HomeDramaGrid extends StatelessWidget {
|
||||||
|
const _HomeDramaGrid({
|
||||||
|
required this.l10n,
|
||||||
|
required this.categories,
|
||||||
|
required this.onOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final CategoryController categories;
|
||||||
|
final ValueChanged<Drama> onOpen;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (categories.loading && categories.feed.sections.isEmpty) {
|
||||||
|
return const SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (categories.error != null) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.categoriesError,
|
||||||
|
style: const TextStyle(color: AppColors.muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: categories.load, child: Text(l10n.retry)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dramas = categories.visibleDramas;
|
||||||
|
if (dramas.isEmpty) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 24),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
categories.selectedSectionMissing
|
||||||
|
? l10n.noCategoryDramas
|
||||||
|
: l10n.noSearchResults,
|
||||||
|
style: const TextStyle(color: AppColors.muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverPadding(
|
||||||
|
key: const Key('home-all-dramas-section'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
key: const Key('home-all-dramas-grid'),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 2 / 3,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _HomeDramaCard(
|
||||||
|
l10n: l10n,
|
||||||
|
drama: dramas[index],
|
||||||
|
onTap: () => onOpen(dramas[index]),
|
||||||
|
),
|
||||||
|
childCount: dramas.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeDramaCard extends StatelessWidget {
|
||||||
|
const _HomeDramaCard({
|
||||||
|
required this.l10n,
|
||||||
|
required this.drama,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final Drama drama;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||||
|
return InkWell(
|
||||||
|
key: Key('home-drama-card-${drama.id}'),
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: AspectRatio(
|
||||||
|
key: Key('home-drama-cover-${drama.id}'),
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
RemoteImage(
|
||||||
|
drama.posterUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
),
|
||||||
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x00000000), Color(0xE6000000)],
|
||||||
|
stops: [0.4, 1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
bottom: 10,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
drama.title,
|
||||||
|
key: Key('home-drama-title-${drama.id}'),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
genre.isEmpty ? l10n.videoPending : genre,
|
||||||
|
key: Key('home-drama-genre-${drama.id}'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xE6FFFFFF),
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/app.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/content_category.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/widgets/remote_image.dart';
|
||||||
|
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/fake_notice_repository.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should_show_all_dramas_in_two_column_sliver_grid_without_continue_watching',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Continue Watching'), findsNothing);
|
||||||
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
|
|
||||||
|
final grid = tester.widget<SliverGrid>(
|
||||||
|
find.byKey(const Key('home-all-dramas-grid')),
|
||||||
|
);
|
||||||
|
final delegate =
|
||||||
|
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||||
|
expect(delegate.crossAxisCount, 2);
|
||||||
|
expect(delegate.crossAxisSpacing, 12);
|
||||||
|
expect(delegate.mainAxisSpacing, 16);
|
||||||
|
expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<SliverPadding>(
|
||||||
|
find.byKey(const Key('home-all-dramas-section')),
|
||||||
|
)
|
||||||
|
.padding,
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
);
|
||||||
|
expect(find.byType(GridView), findsNothing);
|
||||||
|
expect(find.byType(CustomScrollView), findsOneWidget);
|
||||||
|
expect(find.byType(RemoteImage), findsWidgets);
|
||||||
|
|
||||||
|
final cover = tester.widget<AspectRatio>(
|
||||||
|
find.byKey(const Key('home-drama-cover-city-lights-demo')),
|
||||||
|
);
|
||||||
|
expect(cover.aspectRatio, closeTo(2 / 3, 0.001));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(
|
||||||
|
find.byKey(const Key('home-drama-title-city-lights-demo')),
|
||||||
|
)
|
||||||
|
.maxLines,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(
|
||||||
|
find.byKey(const Key('home-drama-genre-city-lights-demo')),
|
||||||
|
)
|
||||||
|
.maxLines,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('should_update_grid_when_category_is_selected', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
|
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Romance'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('The Signal'), findsNothing);
|
||||||
|
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(
|
||||||
|
tester,
|
||||||
|
categoryRepository: _GridCategoryRepository(
|
||||||
|
discoverFeed: const HomeFeed(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
|
expect(find.text('No matching dramas.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Romance'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No dramas in this category yet.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_retry_when_category_feed_fails', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(tester, categoryRepository: _FailingCategoryRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
|
expect(find.text('Categories could not be loaded.'), findsOneWidget);
|
||||||
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 568);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
180,
|
||||||
|
scrollable: find.byType(Scrollable).last,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
|
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpApp(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required CategoryRepository categoryRepository,
|
||||||
|
}) async {
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LumaDramaApp(
|
||||||
|
playerFactory: createWidgetPlayer,
|
||||||
|
catalogRepository: catalog,
|
||||||
|
categoryRepository: categoryRepository,
|
||||||
|
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||||
|
favoriteRepository: InMemoryFavoriteRepository(),
|
||||||
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: FakeCommentRepository(),
|
||||||
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridCategoryRepository implements CategoryRepository {
|
||||||
|
_GridCategoryRepository({HomeFeed? discoverFeed})
|
||||||
|
: discoverFeed = discoverFeed ?? _defaultDiscoverFeed;
|
||||||
|
|
||||||
|
final HomeFeed discoverFeed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContentCategory>> fetchCategories() async => const [
|
||||||
|
ContentCategory(id: '7', name: 'Romance'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchDiscoverFeed() async => discoverFeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FailingCategoryRepository implements CategoryRepository {
|
||||||
|
@override
|
||||||
|
Future<List<ContentCategory>> fetchCategories() async => const [
|
||||||
|
ContentCategory(id: '7', name: 'Romance'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchDiscoverFeed() async {
|
||||||
|
throw StateError('discover feed unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _defaultDiscoverFeed = HomeFeed(
|
||||||
|
sections: [
|
||||||
|
FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas),
|
||||||
|
FeedSection(
|
||||||
|
code: 'category-7',
|
||||||
|
title: 'Romance',
|
||||||
|
dramas: [
|
||||||
|
Drama(
|
||||||
|
id: 'city-lights-demo',
|
||||||
|
title: 'City Lights & Velvet Nights',
|
||||||
|
synopsis: 'City Lights & Velvet Nights synopsis',
|
||||||
|
posterUrl: '/files/city-lights-demo.png',
|
||||||
|
genre: 'Urban Romance',
|
||||||
|
detailLoaded: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
+11
-11
@@ -155,7 +155,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(
|
||||||
@@ -186,15 +186,15 @@ void main() {
|
|||||||
.active,
|
.active,
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
|
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('drawer-discover')));
|
await tester.tap(find.byKey(const Key('drawer-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(tester.element(find.text('Continue Watching'))).canPop(),
|
Navigator.of(tester.element(find.text('All Dramas'))).canPop(),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -313,7 +313,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('For You'), findsOneWidget);
|
expect(find.text('For You'), findsOneWidget);
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
expect(find.text('Rewards'), findsOneWidget);
|
expect(find.text('Rewards'), findsOneWidget);
|
||||||
expect(find.text(r'$ 0'), findsOneWidget);
|
expect(find.text(r'$ 0'), findsOneWidget);
|
||||||
expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
|
expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
|
||||||
@@ -345,10 +345,10 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Home'));
|
await tester.tap(find.text('Home'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
expect(find.byTooltip('Search'), findsNothing);
|
expect(find.byTooltip('Search'), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(tester.element(find.text('Continue Watching'))).canPop(),
|
Navigator.of(tester.element(find.text('All Dramas'))).canPop(),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -365,10 +365,10 @@ void main() {
|
|||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
expect(find.byTooltip('Search'), findsNothing);
|
expect(find.byTooltip('Search'), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(tester.element(find.text('Continue Watching'))).canPop(),
|
Navigator.of(tester.element(find.text('All Dramas'))).canPop(),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my-list')));
|
||||||
@@ -564,7 +564,7 @@ void main() {
|
|||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Quick access'), findsOneWidget);
|
expect(find.text('Quick access'), findsOneWidget);
|
||||||
@@ -572,7 +572,7 @@ void main() {
|
|||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Quick access'), findsNothing);
|
expect(find.text('Quick access'), findsNothing);
|
||||||
expect(find.text('Continue Watching'), findsOneWidget);
|
expect(find.text('All Dramas'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
tester.element(find.byKey(const Key('home-tabs-bar'))),
|
tester.element(find.byKey(const Key('home-tabs-bar'))),
|
||||||
|
|||||||
Reference in new issue
Block a user