feat(flutter): 首页改为两列全部剧目网格

This commit is contained in:
Zhou Weidong committed 2026-10-17 17:24:45 +08:00
1 parent 99f64ab870
commit cf03178cc5
11 files changed
+498 -230

No files matched your search

+1
View File
@@ -55,6 +55,7 @@
"animatedTab": "Animated",
"recommendedTab": "Recommended",
"noCategoryDramas": "No dramas in this category yet.",
"allDramas": "All Dramas",
"trendingTab": "Trending",
"liveActionTab": "Live Action",
"search": "Search",
+1
View File
@@ -55,6 +55,7 @@
"animatedTab": "Animasi",
"recommendedTab": "Rekomendasi",
"noCategoryDramas": "Belum ada drama di kategori ini.",
"allDramas": "Semua Drama",
"trendingTab": "Sedang Tren",
"liveActionTab": "Live Action",
"search": "Cari",
+6
View File
@@ -388,6 +388,12 @@ abstract class AppLocalizations {
/// **'No dramas in this category yet.'**
String get noCategoryDramas;
/// No description provided for @allDramas.
///
/// In en, this message translates to:
/// **'All Dramas'**
String get allDramas;
/// No description provided for @trendingTab.
///
/// In en, this message translates to:
+3
View File
@@ -157,6 +157,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noCategoryDramas => 'No dramas in this category yet.';
@override
String get allDramas => 'All Dramas';
@override
String get trendingTab => 'Trending';
+3
View File
@@ -157,6 +157,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get noCategoryDramas => 'Belum ada drama di kategori ini.';
@override
String get allDramas => 'Semua Drama';
@override
String get trendingTab => 'Sedang Tren';
+3
View File
@@ -154,6 +154,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noCategoryDramas => '这个分类暂时没有剧集。';
@override
String get allDramas => '全部剧目';
@override
String get trendingTab => '热门';
+1
View File
@@ -55,6 +55,7 @@
"animatedTab": "漫剧",
"recommendedTab": "推荐",
"noCategoryDramas": "这个分类暂时没有剧集。",
"allDramas": "全部剧目",
"trendingTab": "热门",
"liveActionTab": "真人剧",
"search": "搜索",
+57 -219
View File
@@ -17,6 +17,8 @@ import 'discover_binding.dart';
import 'discover_logic.dart';
import 'widgets/cover_carousel.dart';
part 'widgets/home_drama_grid.dart';
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
super.key,
@@ -101,40 +103,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
final categories = context.watch<CategoryController>();
final homeBanners = context.watch<CatalogController>().feed.banners;
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final cards = _cardsFor(categories.visibleDramas, '');
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_header(l10n),
Expanded(
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),
],
),
),
],
),
),
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
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) {
return Padding(
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) {
final choices = <({String code, String label})>[
(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 _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,
),
),
],
),
),
],
),
),
),
);
}
}