feat(ui): 重做三 Tab 首页与发现页
This commit is contained in:
1 parent
eae1c533ce
commit
e8c1859e70
23 files changed
+1734
-432
No files matched your search
@@ -1,5 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
@@ -14,6 +16,7 @@ class AppBottomNavItem {
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
this.indicatorKey,
|
||||
});
|
||||
|
||||
final Key tabKey;
|
||||
@@ -22,9 +25,10 @@ class AppBottomNavItem {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
final Key? indicatorKey;
|
||||
}
|
||||
|
||||
/// Shared bottom navigation used by all root tabs and the player fallback.
|
||||
/// Shared 3-tab navigation used by all root tabs and the player fallback.
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({
|
||||
super.key,
|
||||
@@ -38,20 +42,23 @@ class AppBottomNav extends StatelessWidget {
|
||||
final Key? barKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: const Border(
|
||||
top: BorderSide(color: AppColors.outlineVariantDark),
|
||||
Widget build(BuildContext context) => ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withValues(alpha: 0.95),
|
||||
border: const Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [for (final item in items) _NavDestination(item: item)],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [for (final item in items) _NavDestination(item: item)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -71,28 +78,23 @@ class _NavDestination extends StatelessWidget {
|
||||
key: item.tabKey,
|
||||
onTap: item.onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
AnimatedScale(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.selected ? AppColors.blue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
scale: item.selected ? 1 : 0.96,
|
||||
child: Icon(
|
||||
item.selected ? item.selectedIcon : item.icon,
|
||||
size: AppDimens.navIcon,
|
||||
color: item.selected ? AppColors.onPrimary : AppColors.muted,
|
||||
color: item.selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
@@ -100,7 +102,22 @@ class _NavDestination extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: AppDimens.navLabel,
|
||||
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: item.selected ? AppColors.blueSoft : AppColors.muted,
|
||||
color: item.selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.45),
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
AnimatedContainer(
|
||||
key: item.indicatorKey,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
width: AppDimens.navDot,
|
||||
height: AppDimens.navDot,
|
||||
decoration: BoxDecoration(
|
||||
color: item.selected ? AppColors.blue : Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"newReleases": "New Releases",
|
||||
"trendingMicroDramas": "Trending Micro-Dramas",
|
||||
"seeAll": "See All",
|
||||
"trendingSubtitle": "Most binge-watched this week",
|
||||
"verticalMiniSeries": "VERTICAL MINI-SERIES",
|
||||
"followSeries": "Follow Series",
|
||||
"seasonOneVerticalCinema": "Season 1 • Vertical Cinema",
|
||||
"artworkOnly": "Artwork",
|
||||
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
||||
"videoPending": "Video pending",
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"newReleases": "Rilisan Baru",
|
||||
"trendingMicroDramas": "Drama Mini Populer",
|
||||
"seeAll": "Lihat Semua",
|
||||
"trendingSubtitle": "Paling banyak ditonton minggu ini",
|
||||
"verticalMiniSeries": "SERIAL MINI VERTIKAL",
|
||||
"followSeries": "Ikuti Series",
|
||||
"seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal",
|
||||
"artworkOnly": "Gambar",
|
||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||
"videoPending": "Video belum siap",
|
||||
|
||||
@@ -238,6 +238,30 @@ abstract class AppLocalizations {
|
||||
/// **'See All'**
|
||||
String get seeAll;
|
||||
|
||||
/// No description provided for @trendingSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Most binge-watched this week'**
|
||||
String get trendingSubtitle;
|
||||
|
||||
/// No description provided for @verticalMiniSeries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VERTICAL MINI-SERIES'**
|
||||
String get verticalMiniSeries;
|
||||
|
||||
/// No description provided for @followSeries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Follow Series'**
|
||||
String get followSeries;
|
||||
|
||||
/// No description provided for @seasonOneVerticalCinema.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Season 1 • Vertical Cinema'**
|
||||
String get seasonOneVerticalCinema;
|
||||
|
||||
/// No description provided for @artworkOnly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -78,6 +78,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => 'See All';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => 'Most binge-watched this week';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => 'VERTICAL MINI-SERIES';
|
||||
|
||||
@override
|
||||
String get followSeries => 'Follow Series';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema';
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Artwork';
|
||||
|
||||
|
||||
@@ -78,6 +78,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => 'Lihat Semua';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => 'Paling banyak ditonton minggu ini';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => 'SERIAL MINI VERTIKAL';
|
||||
|
||||
@override
|
||||
String get followSeries => 'Ikuti Series';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal';
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Gambar';
|
||||
|
||||
|
||||
@@ -77,6 +77,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => '查看全部';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => '本周追看最多';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => '竖屏短剧';
|
||||
|
||||
@override
|
||||
String get followSeries => '追剧';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院';
|
||||
|
||||
@override
|
||||
String get artworkOnly => '海报展示';
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"newReleases": "新剧",
|
||||
"trendingMicroDramas": "热门短剧",
|
||||
"seeAll": "查看全部",
|
||||
"trendingSubtitle": "本周追看最多",
|
||||
"verticalMiniSeries": "竖屏短剧",
|
||||
"followSeries": "追剧",
|
||||
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
|
||||
"artworkOnly": "海报展示",
|
||||
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
||||
"videoPending": "视频待提供",
|
||||
|
||||
@@ -8,8 +8,5 @@ class DiscoverBinding {
|
||||
DiscoverLogic create({
|
||||
required Drama drama,
|
||||
required WatchHistoryRepository history,
|
||||
}) => DiscoverLogic(
|
||||
drama: drama,
|
||||
history: history,
|
||||
);
|
||||
}) => DiscoverLogic(drama: drama, history: history);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
Drama currentDrama;
|
||||
int tab = 1;
|
||||
int tab = 0;
|
||||
int searchRequest = 0;
|
||||
int sectionRequest = 0;
|
||||
int resumeRequest = 0;
|
||||
|
||||
@@ -244,35 +244,30 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
barKey: const Key('home-tabs-bar'),
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-discover'),
|
||||
tabKey: const Key('tab-home'),
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 1,
|
||||
onTap: () => _selectTab(context, 1),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-home'),
|
||||
icon: Icons.movie_outlined,
|
||||
selectedIcon: Icons.movie_rounded,
|
||||
label: l10n.shortsTab,
|
||||
selected: logic.tab == 0,
|
||||
indicatorKey: const Key('tab-home-indicator'),
|
||||
onTap: () => _selectTab(context, 0),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-my-list'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
selectedIcon: Icons.bookmark_rounded,
|
||||
label: l10n.myListTab,
|
||||
selected: logic.tab == 2 && !logic.profile,
|
||||
onTap: () => _selectTab(context, 2),
|
||||
tabKey: const Key('tab-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
selectedIcon: Icons.explore_rounded,
|
||||
label: l10n.discoverTab,
|
||||
selected: logic.tab == 1,
|
||||
indicatorKey: const Key('tab-discover-indicator'),
|
||||
onTap: () => _selectTab(context, 1),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-my'),
|
||||
icon: Icons.person_outline_rounded,
|
||||
selectedIcon: Icons.person_rounded,
|
||||
label: l10n.myTab,
|
||||
selected: logic.tab == 2 && logic.profile,
|
||||
selected: logic.tab == 2,
|
||||
indicatorKey: const Key('tab-my-indicator'),
|
||||
onTap: () => _selectTab(context, 2, myProfile: true),
|
||||
),
|
||||
],
|
||||
@@ -282,9 +277,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
||||
final same = tab == 2
|
||||
? logic.tab == 2 && logic.profile == myProfile
|
||||
: logic.tab == tab;
|
||||
final same = logic.tab == tab;
|
||||
if (!same) {
|
||||
unawaited(context.read<Haptics>().selectionClick());
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ class _MyPageState extends State<MyPage> {
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
if (!widget.profile) _topBar(l10n),
|
||||
_topBar(l10n),
|
||||
Expanded(
|
||||
child: FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
@@ -132,11 +132,11 @@ class _MyPageState extends State<MyPage> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
children: [
|
||||
if (widget.profile)
|
||||
..._accountPage(l10n)
|
||||
else ...[
|
||||
_library(l10n, position, total, fraction),
|
||||
],
|
||||
if (widget.profile) ..._accountPage(l10n),
|
||||
if (widget.profile) const SizedBox(height: 24),
|
||||
_library(l10n, position, total, fraction),
|
||||
if (!widget.profile) const SizedBox(height: 24),
|
||||
if (!widget.profile) ..._accountPage(l10n),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Text(
|
||||
@@ -174,7 +174,7 @@ class _MyPageState extends State<MyPage> {
|
||||
const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.myListTab,
|
||||
widget.profile ? l10n.myTab : l10n.myListTab,
|
||||
style: const TextStyle(
|
||||
fontSize: AppDimens.pageTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -548,6 +548,7 @@ class _MyPageState extends State<MyPage> {
|
||||
selected: logic.section == index,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
key: Key('mine-library-tab-$index'),
|
||||
onTap: () {
|
||||
logic.selectSection(index);
|
||||
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
import 'widgets/home_feed_overlay.dart';
|
||||
import 'widgets/seek_progress_bar.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
@@ -334,6 +335,57 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
)
|
||||
else if (!logic.screenCleared && widget.homeMode)
|
||||
HomeFeedOverlay(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: interactions.isLiked(drama.id),
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final result = await interactions.toggleDramaLike(drama.id);
|
||||
if (!result.isSuccess && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.errorMessage ??
|
||||
AppLocalizations.of(context)!.likeFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSave: () async {
|
||||
final favorites = context.read<FavoriteController>();
|
||||
if (await ensureSignedIn(context)) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final ok = await favorites.toggle(drama.id, card: drama);
|
||||
if (ok || !context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.favoriteToggleFailed)),
|
||||
);
|
||||
}
|
||||
},
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onClear: () => logic.setScreenCleared(true),
|
||||
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
? () => unawaited(logic.enterPip(context))
|
||||
: null,
|
||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
)
|
||||
else if (!logic.screenCleared)
|
||||
_PlaybackOverlay(
|
||||
l10n: l10n,
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/glass_panel.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'seek_progress_bar.dart';
|
||||
|
||||
/// Immersive home layer from `screens/01-home.html`.
|
||||
///
|
||||
/// Playback, ticket, progress, favorite, like, comment, and share callbacks are
|
||||
/// all owned by [PlayerPage]; this widget only paints the design overlay.
|
||||
class HomeFeedOverlay extends StatelessWidget {
|
||||
const HomeFeedOverlay({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
required this.episode,
|
||||
required this.controller,
|
||||
required this.liked,
|
||||
required this.saved,
|
||||
required this.onLike,
|
||||
required this.onSave,
|
||||
required this.onComments,
|
||||
required this.onShare,
|
||||
required this.onEpisodes,
|
||||
required this.onDownload,
|
||||
required this.onClear,
|
||||
required this.onSearch,
|
||||
required this.onScrubStart,
|
||||
required this.onSeek,
|
||||
this.onPip,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final Episode episode;
|
||||
final VideoPlayerController? controller;
|
||||
final bool liked;
|
||||
final bool saved;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback? onPip;
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final compact = MediaQuery.sizeOf(context).height < 620;
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_header(context),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 12, 4),
|
||||
child: _rail(compact),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||||
child: _infoCard(context, compact),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
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: 7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
key: const Key('home-feed-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
shadows: [
|
||||
Shadow(blurRadius: 10, color: Colors.black87),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.64),
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 8, color: Colors.black87),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_GlassIconButton(
|
||||
tooltip: l10n.search,
|
||||
icon: Icons.search_rounded,
|
||||
onPressed: onSearch,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_GlassIconButton(
|
||||
tooltip: l10n.clearScreen,
|
||||
icon: Icons.fit_screen_outlined,
|
||||
onPressed: onClear,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_GlassIconButton(
|
||||
tooltip: l10n.more,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hotBadge() {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
child: Text(
|
||||
l10n.hot,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.4,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rail(bool compact) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
label: saved ? l10n.followingTab : l10n.followSeries,
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-follow'),
|
||||
onTap: onSave,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: compact ? 40 : 44,
|
||||
height: compact ? 40 : 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.blue, width: 2),
|
||||
color: AppColors.raised,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -4,
|
||||
left: 14,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: saved ? AppColors.success : AppColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.ink, width: 2),
|
||||
),
|
||||
child: Icon(
|
||||
saved ? Icons.check_rounded : Icons.add_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RailAction(
|
||||
compact: compact,
|
||||
key: const Key('rail-like'),
|
||||
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
label: l10n.like,
|
||||
selected: liked,
|
||||
onTap: onLike,
|
||||
),
|
||||
_RailAction(
|
||||
compact: compact,
|
||||
key: const Key('rail-save'),
|
||||
icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
||||
label: l10n.save,
|
||||
selected: saved,
|
||||
onTap: onSave,
|
||||
),
|
||||
_RailAction(
|
||||
compact: compact,
|
||||
key: const Key('rail-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
),
|
||||
_RailAction(
|
||||
compact: compact,
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.share_outlined,
|
||||
label: l10n.share,
|
||||
onTap: onShare,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard(BuildContext context, bool compact) {
|
||||
final genres = [
|
||||
if (drama.genre.isNotEmpty) drama.genre,
|
||||
if (drama.category.isNotEmpty) drama.category,
|
||||
].take(2).toList();
|
||||
return GlassPanel(
|
||||
key: const Key('home-feed-info-card'),
|
||||
radius: 26,
|
||||
blur: 24,
|
||||
padding: const EdgeInsets.fromLTRB(16, 13, 16, 12),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0x260B0C10), Color(0x590B0C10)],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 5,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (var i = 0; i < genres.length; i++)
|
||||
_genreChip(genres[i], i.isEven),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 5,
|
||||
height: 5,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${l10n.episodeLabel(episode.number)} / '
|
||||
'${drama.episodeCount}',
|
||||
key: const Key('home-feed-episode'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
drama.synopsis,
|
||||
maxLines: compact ? 1 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 12,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SeekProgressBar(
|
||||
valueListenable: controller,
|
||||
semanticsLabel: l10n.seekVideo,
|
||||
showTimes: false,
|
||||
compact: true,
|
||||
onScrubStart: onScrubStart,
|
||||
onSeek: onSeek,
|
||||
),
|
||||
_timeRow(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('home-feed-follow-series'),
|
||||
onPressed: onSave,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
saved ? Icons.check_rounded : Icons.add_rounded,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(
|
||||
saved ? l10n.followingTab : l10n.followSeries,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_GlassAction(
|
||||
tooltip: l10n.episodes,
|
||||
icon: Icons.grid_view_rounded,
|
||||
label: l10n.episodes,
|
||||
onPressed: onEpisodes,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _genreChip(String label, bool secondary) {
|
||||
final color = secondary ? AppColors.secondary : AppColors.blue;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeRow() {
|
||||
final source = controller;
|
||||
if (source == null) {
|
||||
return const _Times(position: Duration.zero, duration: Duration.zero);
|
||||
}
|
||||
return ValueListenableBuilder<VideoPlayerValue>(
|
||||
valueListenable: source,
|
||||
builder: (context, value, _) =>
|
||||
_Times(position: value.position, duration: value.duration),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openMore(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: const Key('home-feed-download'),
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.downloads),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onDownload();
|
||||
},
|
||||
),
|
||||
if (onPip != null)
|
||||
ListTile(
|
||||
key: const Key('home-feed-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onPip!();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.fit_screen_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
title: Text(l10n.clearScreen),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onClear();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Times extends StatelessWidget {
|
||||
const _Times({required this.position, required this.duration});
|
||||
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_format(position),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
Text(
|
||||
_format(duration),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlassIconButton extends StatelessWidget {
|
||||
const _GlassIconButton({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.28),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.16)),
|
||||
),
|
||||
icon: Icon(icon, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlassAction extends StatelessWidget {
|
||||
const _GlassAction({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: AppColors.secondary, size: 15),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RailAction extends StatelessWidget {
|
||||
const _RailAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: compact ? 2 : 3),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GlassPanel(
|
||||
radius: 999,
|
||||
blur: 16,
|
||||
child: SizedBox(
|
||||
width: compact ? 40 : 38,
|
||||
height: compact ? 40 : 38,
|
||||
child: IconButton(
|
||||
tooltip: label,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: 19,
|
||||
color: selected ? AppColors.blue : AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!compact) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _format(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';
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.showTimes = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showTimes;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: scrubbing ? 48 : 44,
|
||||
height: widget.compact
|
||||
? (scrubbing ? 32 : 20)
|
||||
: (scrubbing ? 48 : 44),
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: scrubbing ? 4 : 3,
|
||||
activeTrackColor: AppColors.gold,
|
||||
inactiveTrackColor: Colors.white30,
|
||||
thumbColor: Colors.white,
|
||||
trackHeight: widget.compact
|
||||
? (scrubbing ? 3 : 1.5)
|
||||
: (scrubbing ? 4 : 3),
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.compact
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: Colors.white30,
|
||||
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
enabledThumbRadius: widget.compact
|
||||
? (scrubbing ? 6 : 4)
|
||||
: (scrubbing ? 8 : 6),
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
|
||||
Reference in new issue
Block a user