feat(ui): 重做三 Tab 首页与发现页

This commit is contained in:
Zhou Weidong committed 2026-10-19 15:01:45 +08:00
1 parent eae1c533ce
commit e8c1859e70
23 files changed
+1734 -432

No files matched your search

+1 -4
View File
@@ -8,8 +8,5 @@ class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
required WatchHistoryRepository history,
}) => DiscoverLogic(
drama: drama,
history: history,
);
}) => DiscoverLogic(drama: drama, history: history);
}
+40
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart';
@@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier {
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
WatchProgress? resumeRecord;
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
@@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier {
.resumePointFor(drama.id)
.then((point) => point?.position)
.catchError((Object _) => null);
refreshResume();
}
Future<void> refreshResume() async {
try {
final recent = await history.recent(limit: 1);
if (recent.isEmpty || recent.first.finished) {
resumeRecord = null;
} else {
resumeRecord = recent.first;
}
} catch (_) {
resumeRecord = null;
}
notify();
}
void onSearchChanged() => notify();
List<Drama> filtered(List<Drama> dramas) {
final query = search.text.trim().toLowerCase();
if (query.isEmpty) return dramas;
return dramas.where((drama) {
final haystack = [
drama.title,
drama.genre,
drama.category,
drama.synopsis,
].join(' ').toLowerCase();
return haystack.contains(query);
}).toList();
}
Drama? resumeDrama(List<Drama> dramas) {
final record = resumeRecord;
if (record == null) return null;
for (final drama in dramas) {
if (drama.id == record.dramaId) return drama;
}
return null;
}
bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() {
+448 -171
View File
@@ -5,20 +5,19 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/glass_panel.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
import 'widgets/cover_carousel.dart';
part 'widgets/home_drama_grid.dart';
@@ -70,7 +69,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
drama: widget.drama,
history: widget.history ?? context.read<WatchHistoryRepository>(),
);
if (widget.focusSearch) logic.searchOpen = true;
if (widget.focusSearch) {
logic.searchOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
}
}
@override
@@ -104,14 +108,13 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>();
final homeBanners = context.watch<CatalogController>().feed.banners;
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final visible = logic.filtered(categories.visibleDramas);
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_header(l10n),
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
Expanded(child: _feed(l10n, categories, allDramas, visible)),
if (!widget.embedded) _bottomNav(l10n),
],
),
@@ -130,29 +133,96 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _feed(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
List<Drama> allDramas,
List<Drama> visible,
) => CustomScrollView(
controller: logic.scroll,
slivers: [
SliverToBoxAdapter(child: _header(l10n)),
SliverToBoxAdapter(child: _search()),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _carousel(l10n, banners, allDramas),
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
child: _categories(l10n, categories),
),
),
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _continueCard(
l10n,
logic.resumeDrama(allDramas)!,
logic.resumeRecord!,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _quickActions(l10n, categories),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
child: _categories(l10n, categories),
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.trendingMicroDramas,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
l10n.trendingSubtitle,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
),
),
],
),
),
TextButton(
onPressed: () =>
categories.selectCode(CategoryController.hotCode),
style: TextButton.styleFrom(
foregroundColor: AppColors.blue,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.seeAll,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Icon(Icons.chevron_right_rounded, size: 16),
],
),
),
],
),
),
),
_HomeDramaGrid(
l10n: l10n,
categories: categories,
dramas: visible,
searching: logic.search.text.trim().isNotEmpty,
onOpen: (drama) => logic.openCard(
context,
drama,
@@ -166,93 +236,129 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _header(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
child: Row(
children: [
const _BrandMark(),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 20,
fontWeight: FontWeight.w800,
height: 1.1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 21,
fontWeight: FontWeight.w700,
height: 1.05,
letterSpacing: 0,
),
),
),
],
),
const SizedBox(height: 3),
Text(
l10n.verticalMiniSeries,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10,
fontWeight: FontWeight.w500,
letterSpacing: 0.6,
),
),
],
),
),
const SizedBox(width: 12),
Semantics(
button: true,
label: widget.signedIn ? l10n.myTab : l10n.logIn,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.raised,
border: Border.all(
color: AppColors.secondary.withValues(alpha: 0.7),
width: 2,
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 21,
),
),
),
),
const SizedBox(width: 8),
if (widget.signedIn) ...[
_HeaderPill(
icon: Icons.monetization_on_rounded,
label: r'$ 0',
onTap: widget.onCoins,
),
const SizedBox(width: 8),
_HeaderPill(
icon: Icons.card_giftcard_rounded,
label: l10n.rewards,
onTap: widget.onRewards,
),
] else
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
],
),
);
}
Widget _carousel(
AppLocalizations l10n,
List<FeedBanner> banners,
List<Drama> allDramas,
) {
final items = banners
.where((banner) => banner.imageUrl.isNotEmpty)
.toList();
if (items.isEmpty) return const SizedBox.shrink();
return CoverCarousel(
active: widget.active,
items: [
for (final banner in items)
CoverItem(
title: _bannerTitle(banner, allDramas),
subtitle: '',
image: banner.imageUrl,
onOpen: () => _openBanner(banner, allDramas),
Widget _search() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: AppDimens.searchHeight,
child: TextField(
key: const Key('discover-search'),
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.card,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 20,
),
suffixIcon: const Icon(
Icons.mic_none_rounded,
color: AppColors.muted,
size: 19,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
borderSide: const BorderSide(color: AppColors.blue, width: 1.2),
),
),
],
),
),
);
}
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return '';
for (final drama in allDramas) {
if (drama.id == dramaId) return drama.title;
}
return '';
}
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return;
for (final drama in allDramas) {
if (drama.id == dramaId) {
logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
);
return;
}
}
}
Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.all),
@@ -265,6 +371,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
key: const Key('home-category-chips'),
height: AppDimens.tapTarget,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
@@ -281,17 +388,22 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14),
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color: selected ? AppColors.blue : AppColors.raised,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
color: selected ? AppColors.blue : AppColors.card,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected
? AppColors.blue
: Colors.white.withValues(alpha: 0.08),
),
),
child: Text(
choice.label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.cream,
fontSize: AppDimens.body,
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1,
),
@@ -305,12 +417,208 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
Widget _continueCard(
AppLocalizations l10n,
Drama drama,
WatchProgress record,
) {
final progress = record.fraction;
return GlassPanel(
key: const Key('discover-continue-watching'),
radius: AppDimens.glassSmallRadius,
blur: 18,
padding: const EdgeInsets.all(12),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)],
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 64,
height: 88,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Flexible(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.blue,
fontSize: 9.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
),
],
),
const SizedBox(height: 3),
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
_resumeMeta(drama, record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10.5,
),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progress <= 0 ? null : progress,
minHeight: 3,
backgroundColor: Colors.white.withValues(alpha: 0.12),
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
],
),
),
const SizedBox(width: 10),
FilledButton(
key: const Key('discover-continue-play'),
onPressed: () => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(64, 34),
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.play),
const SizedBox(width: 2),
const Icon(Icons.play_arrow_rounded, size: 14),
],
),
),
],
),
);
}
String _resumeMeta(Drama drama, WatchProgress record) {
final label = AppLocalizations.of(context)!;
Episode? episode;
for (final item in drama.episodes) {
if (item.id == record.episodeId) {
episode = item;
break;
}
}
final head = episode == null
? ''
: '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · ';
return '$head${_duration(record.position)} / ${_duration(record.duration)}';
}
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
return Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('discover-quick-filter'),
icon: Icons.tune_rounded,
label: l10n.filter,
color: AppColors.secondary,
onTap: logic.openSearch,
),
),
const SizedBox(width: 8),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-charts'),
icon: Icons.local_fire_department_rounded,
label: l10n.topCharts,
color: AppColors.blue,
onTap: () => categories.selectCode(CategoryController.hotCode),
),
),
const SizedBox(width: 8),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-new'),
icon: Icons.auto_awesome_rounded,
label: l10n.newest,
color: AppColors.gold,
onTap: () => categories.selectCode(CategoryController.newCode),
),
),
const SizedBox(width: 8),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
color: AppColors.success,
onTap: () => logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
),
],
);
}
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('discover-nav-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
onTap: () => logic.act(
context,
@@ -329,8 +637,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
),
AppBottomNavItem(
tabKey: const Key('discover-nav-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
onTap: () => logic.act(
context,
@@ -343,72 +651,61 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
const _coin = AppColors.gold;
class _BrandMark extends StatelessWidget {
const _BrandMark();
@override
Widget build(BuildContext context) {
return Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 20,
),
);
}
}
class _HeaderPill extends StatelessWidget {
const _HeaderPill({
class _QuickAction extends StatelessWidget {
const _QuickAction({
super.key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.raised,
shape: StadiumBorder(
side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)),
),
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: _coin),
const SizedBox(width: 4),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.cream,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w700,
height: 1.1,
),
child: Container(
height: 76,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
],
),
child: Icon(icon, color: color, size: 17),
),
const SizedBox(height: 5),
Text(
label,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 9.5,
fontWeight: FontWeight.w500,
height: 1.05,
),
),
],
),
),
),
@@ -416,28 +713,8 @@ class _HeaderPill extends StatelessWidget {
}
}
class _LoginButton extends StatelessWidget {
const _LoginButton({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.blue,
padding: const EdgeInsets.symmetric(horizontal: 14),
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontSize: AppDimens.body,
fontWeight: FontWeight.w600,
),
),
child: Text(label, maxLines: 1),
);
}
String _duration(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
+145 -90
View File
@@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget {
const _HomeDramaGrid({
required this.l10n,
required this.categories,
required this.dramas,
required this.searching,
required this.onOpen,
});
final AppLocalizations l10n;
final CategoryController categories;
final List<Drama> dramas;
final bool searching;
final ValueChanged<Drama> onOpen;
@override
@@ -34,16 +38,21 @@ class _HomeDramaGrid extends StatelessWidget {
);
}
final dramas = categories.visibleDramas;
if (dramas.isEmpty) {
final missingCategory = categories.selectedSectionMissing;
return _fill(
EmptyState(
key: const Key('home-all-dramas-empty'),
icon: missingCategory
icon: searching
? Icons.search_off_rounded
: missingCategory
? Icons.grid_view_rounded
: Icons.search_off_rounded,
title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults,
: Icons.movie_filter_outlined,
title: searching
? l10n.noSearchResults
: missingCategory
? l10n.noCategoryDramas
: l10n.noSearchResults,
subtitle: l10n.emptyHint,
),
);
@@ -57,24 +66,24 @@ class _HomeDramaGrid extends StatelessWidget {
);
}
Widget _dramaGrid(List<Drama> dramas) => SliverPadding(
Widget _dramaGrid(List<Drama> items) => SliverPadding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
sliver: SliverGrid(
key: const Key('home-all-dramas-grid'),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisCount: 2,
mainAxisSpacing: 14,
crossAxisSpacing: 10,
childAspectRatio: 2 / 3,
crossAxisSpacing: 12,
childAspectRatio: 0.55,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _HomeDramaCard(
l10n: l10n,
drama: dramas[index],
onTap: () => onOpen(dramas[index]),
drama: items[index],
onTap: () => onOpen(items[index]),
),
childCount: dramas.length,
childCount: items.length,
),
),
);
@@ -102,95 +111,141 @@ class _HomeDramaCard extends StatelessWidget {
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
child: AspectRatio(
key: Key('home-drama-cover-${drama.id}'),
aspectRatio: 2 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
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(0xE6100C0A)],
stops: [0.42, 1],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
key: Key('home-drama-cover-${drama.id}'),
aspectRatio: 3 / 4.2,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
if (drama.isHot)
Positioned(
top: 8,
left: 8,
child: DecoratedBox(
const DecoratedBox(
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x33000000),
Color(0x00000000),
Color(0xB3000000),
],
stops: [0, 0.42, 1],
),
child: Text(
l10n.hot,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w800,
height: 1.1,
),
),
Positioned(
top: 8,
left: 8,
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (drama.isHot) _badge(l10n.hot, AppColors.blue),
if (drama.freeEpisodes > 0)
_badge(l10n.free, AppColors.success),
],
),
),
if (drama.viewCount > 0)
Positioned(
right: 8,
bottom: 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
child: Text(
_compactCount(drama.viewCount),
key: Key('home-drama-views-${drama.id}'),
style: const TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
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: AppColors.cream,
fontSize: AppDimens.body,
fontWeight: FontWeight.w800,
height: 1.15,
),
),
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: AppColors.cream,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
],
),
],
),
],
),
),
const SizedBox(height: 8),
Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 3),
Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
],
),
);
}
Widget _badge(String label, Color color) {
return DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
child: Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
height: 1.1,
),
),
),
);
}
}
String _compactCount(int value) {
if (value >= 1000000) {
final scaled = value / 1000000;
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M';
}
if (value >= 1000) {
final scaled = value / 1000;
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K';
}
return '$value';
}