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
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
@@ -14,6 +16,7 @@ class AppBottomNavItem {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
this.indicatorKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Key tabKey;
|
final Key tabKey;
|
||||||
@@ -22,9 +25,10 @@ class AppBottomNavItem {
|
|||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool selected;
|
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 {
|
class AppBottomNav extends StatelessWidget {
|
||||||
const AppBottomNav({
|
const AppBottomNav({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -38,20 +42,23 @@ class AppBottomNav extends StatelessWidget {
|
|||||||
final Key? barKey;
|
final Key? barKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => SafeArea(
|
Widget build(BuildContext context) => ClipRect(
|
||||||
top: false,
|
child: BackdropFilter(
|
||||||
child: Container(
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||||
key: barKey,
|
child: SafeArea(
|
||||||
height: AppDimens.navHeight,
|
top: false,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: backgroundColor,
|
key: barKey,
|
||||||
border: const Border(
|
height: AppDimens.navHeight,
|
||||||
top: BorderSide(color: AppColors.outlineVariantDark),
|
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,
|
key: item.tabKey,
|
||||||
onTap: item.onTap,
|
onTap: item.onTap,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedScale(
|
||||||
duration: const Duration(milliseconds: 160),
|
duration: const Duration(milliseconds: 160),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
padding: const EdgeInsets.symmetric(
|
scale: item.selected ? 1 : 0.96,
|
||||||
horizontal: 14,
|
|
||||||
vertical: 3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: item.selected ? AppColors.blue : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
|
||||||
),
|
|
||||||
child: Icon(
|
child: Icon(
|
||||||
item.selected ? item.selectedIcon : item.icon,
|
item.selected ? item.selectedIcon : item.icon,
|
||||||
size: AppDimens.navIcon,
|
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(
|
Text(
|
||||||
item.label,
|
item.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -100,7 +102,22 @@ class _NavDestination extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppDimens.navLabel,
|
fontSize: AppDimens.navLabel,
|
||||||
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
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",
|
"newReleases": "New Releases",
|
||||||
"trendingMicroDramas": "Trending Micro-Dramas",
|
"trendingMicroDramas": "Trending Micro-Dramas",
|
||||||
"seeAll": "See All",
|
"seeAll": "See All",
|
||||||
|
"trendingSubtitle": "Most binge-watched this week",
|
||||||
|
"verticalMiniSeries": "VERTICAL MINI-SERIES",
|
||||||
|
"followSeries": "Follow Series",
|
||||||
|
"seasonOneVerticalCinema": "Season 1 • Vertical Cinema",
|
||||||
"artworkOnly": "Artwork",
|
"artworkOnly": "Artwork",
|
||||||
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
||||||
"videoPending": "Video pending",
|
"videoPending": "Video pending",
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
"newReleases": "Rilisan Baru",
|
"newReleases": "Rilisan Baru",
|
||||||
"trendingMicroDramas": "Drama Mini Populer",
|
"trendingMicroDramas": "Drama Mini Populer",
|
||||||
"seeAll": "Lihat Semua",
|
"seeAll": "Lihat Semua",
|
||||||
|
"trendingSubtitle": "Paling banyak ditonton minggu ini",
|
||||||
|
"verticalMiniSeries": "SERIAL MINI VERTIKAL",
|
||||||
|
"followSeries": "Ikuti Series",
|
||||||
|
"seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal",
|
||||||
"artworkOnly": "Gambar",
|
"artworkOnly": "Gambar",
|
||||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||||
"videoPending": "Video belum siap",
|
"videoPending": "Video belum siap",
|
||||||
|
|||||||
@@ -238,6 +238,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'See All'**
|
/// **'See All'**
|
||||||
String get seeAll;
|
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.
|
/// No description provided for @artworkOnly.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get seeAll => 'See All';
|
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
|
@override
|
||||||
String get artworkOnly => 'Artwork';
|
String get artworkOnly => 'Artwork';
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get seeAll => 'Lihat Semua';
|
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
|
@override
|
||||||
String get artworkOnly => 'Gambar';
|
String get artworkOnly => 'Gambar';
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get seeAll => '查看全部';
|
String get seeAll => '查看全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trendingSubtitle => '本周追看最多';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get verticalMiniSeries => '竖屏短剧';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get followSeries => '追剧';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artworkOnly => '海报展示';
|
String get artworkOnly => '海报展示';
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
"newReleases": "新剧",
|
"newReleases": "新剧",
|
||||||
"trendingMicroDramas": "热门短剧",
|
"trendingMicroDramas": "热门短剧",
|
||||||
"seeAll": "查看全部",
|
"seeAll": "查看全部",
|
||||||
|
"trendingSubtitle": "本周追看最多",
|
||||||
|
"verticalMiniSeries": "竖屏短剧",
|
||||||
|
"followSeries": "追剧",
|
||||||
|
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
|
||||||
"artworkOnly": "海报展示",
|
"artworkOnly": "海报展示",
|
||||||
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
||||||
"videoPending": "视频待提供",
|
"videoPending": "视频待提供",
|
||||||
|
|||||||
@@ -8,8 +8,5 @@ class DiscoverBinding {
|
|||||||
DiscoverLogic create({
|
DiscoverLogic create({
|
||||||
required Drama drama,
|
required Drama drama,
|
||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
}) => DiscoverLogic(
|
}) => DiscoverLogic(drama: drama, history: history);
|
||||||
drama: drama,
|
|
||||||
history: history,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/models/watch_progress.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
Drama drama;
|
Drama drama;
|
||||||
late Future<Duration?> position;
|
late Future<Duration?> position;
|
||||||
|
WatchProgress? resumeRecord;
|
||||||
final TextEditingController search = TextEditingController();
|
final TextEditingController search = TextEditingController();
|
||||||
final FocusNode searchFocus = FocusNode();
|
final FocusNode searchFocus = FocusNode();
|
||||||
final ScrollController scroll = ScrollController();
|
final ScrollController scroll = ScrollController();
|
||||||
@@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
.resumePointFor(drama.id)
|
.resumePointFor(drama.id)
|
||||||
.then((point) => point?.position)
|
.then((point) => point?.position)
|
||||||
.catchError((Object _) => null);
|
.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();
|
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;
|
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||||
|
|
||||||
void openSearch() {
|
void openSearch() {
|
||||||
|
|||||||
@@ -5,20 +5,19 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.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/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/catalog_controller.dart';
|
|
||||||
import '../../core/data/services/category_controller.dart';
|
import '../../core/data/services/category_controller.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/app_bottom_nav.dart';
|
import '../../core/widgets/app_bottom_nav.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
import '../../core/widgets/glass_panel.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'discover_binding.dart';
|
import 'discover_binding.dart';
|
||||||
import 'discover_logic.dart';
|
import 'discover_logic.dart';
|
||||||
import 'widgets/cover_carousel.dart';
|
|
||||||
|
|
||||||
part 'widgets/home_drama_grid.dart';
|
part 'widgets/home_drama_grid.dart';
|
||||||
|
|
||||||
@@ -70,7 +69,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
drama: widget.drama,
|
drama: widget.drama,
|
||||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
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
|
@override
|
||||||
@@ -104,14 +108,13 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
Widget _page(BuildContext context) {
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final categories = context.watch<CategoryController>();
|
final categories = context.watch<CategoryController>();
|
||||||
final homeBanners = context.watch<CatalogController>().feed.banners;
|
|
||||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||||
|
final visible = logic.filtered(categories.visibleDramas);
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_header(l10n),
|
Expanded(child: _feed(l10n, categories, allDramas, visible)),
|
||||||
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
|
|
||||||
if (!widget.embedded) _bottomNav(l10n),
|
if (!widget.embedded) _bottomNav(l10n),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -130,29 +133,96 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
Widget _feed(
|
Widget _feed(
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
CategoryController categories,
|
CategoryController categories,
|
||||||
List<FeedBanner> banners,
|
|
||||||
List<Drama> allDramas,
|
List<Drama> allDramas,
|
||||||
|
List<Drama> visible,
|
||||||
) => CustomScrollView(
|
) => CustomScrollView(
|
||||||
controller: logic.scroll,
|
controller: logic.scroll,
|
||||||
slivers: [
|
slivers: [
|
||||||
|
SliverToBoxAdapter(child: _header(l10n)),
|
||||||
|
SliverToBoxAdapter(child: _search()),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
|
||||||
child: ClipRRect(
|
child: _categories(l10n, categories),
|
||||||
borderRadius: BorderRadius.circular(18),
|
),
|
||||||
child: _carousel(l10n, banners, allDramas),
|
),
|
||||||
|
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(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
|
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
|
||||||
child: _categories(l10n, categories),
|
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(
|
_HomeDramaGrid(
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
categories: categories,
|
categories: categories,
|
||||||
|
dramas: visible,
|
||||||
|
searching: logic.search.text.trim().isNotEmpty,
|
||||||
onOpen: (drama) => logic.openCard(
|
onOpen: (drama) => logic.openCard(
|
||||||
context,
|
context,
|
||||||
drama,
|
drama,
|
||||||
@@ -166,93 +236,129 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
|
|
||||||
Widget _header(AppLocalizations l10n) {
|
Widget _header(AppLocalizations l10n) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const _BrandMark(),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
l10n.appName,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
maxLines: 1,
|
children: [
|
||||||
overflow: TextOverflow.ellipsis,
|
Row(
|
||||||
style: const TextStyle(
|
children: [
|
||||||
color: AppColors.cream,
|
Container(
|
||||||
fontSize: 20,
|
width: 8,
|
||||||
fontWeight: FontWeight.w800,
|
height: 8,
|
||||||
height: 1.1,
|
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(
|
Widget _search() {
|
||||||
AppLocalizations l10n,
|
return Padding(
|
||||||
List<FeedBanner> banners,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
List<Drama> allDramas,
|
child: SizedBox(
|
||||||
) {
|
height: AppDimens.searchHeight,
|
||||||
final items = banners
|
child: TextField(
|
||||||
.where((banner) => banner.imageUrl.isNotEmpty)
|
key: const Key('discover-search'),
|
||||||
.toList();
|
controller: logic.search,
|
||||||
if (items.isEmpty) return const SizedBox.shrink();
|
focusNode: logic.searchFocus,
|
||||||
return CoverCarousel(
|
onChanged: (_) => logic.onSearchChanged(),
|
||||||
active: widget.active,
|
textInputAction: TextInputAction.search,
|
||||||
items: [
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
||||||
for (final banner in items)
|
decoration: InputDecoration(
|
||||||
CoverItem(
|
isDense: true,
|
||||||
title: _bannerTitle(banner, allDramas),
|
filled: true,
|
||||||
subtitle: '',
|
fillColor: AppColors.card,
|
||||||
image: banner.imageUrl,
|
hintText: AppLocalizations.of(context)!.searchDiscover,
|
||||||
onOpen: () => _openBanner(banner, allDramas),
|
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) {
|
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
||||||
final choices = <({String code, String label})>[
|
final choices = <({String code, String label})>[
|
||||||
(code: CategoryController.allCode, label: l10n.all),
|
(code: CategoryController.allCode, label: l10n.all),
|
||||||
@@ -265,6 +371,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
key: const Key('home-category-chips'),
|
key: const Key('home-category-chips'),
|
||||||
height: AppDimens.tapTarget,
|
height: AppDimens.tapTarget,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: choices.length,
|
itemCount: choices.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
@@ -281,17 +388,22 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? AppColors.blue : AppColors.raised,
|
color: selected ? AppColors.blue : AppColors.card,
|
||||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected
|
||||||
|
? AppColors.blue
|
||||||
|
: Colors.white.withValues(alpha: 0.08),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
choice.label,
|
choice.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: selected ? AppColors.onPrimary : AppColors.cream,
|
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||||
fontSize: AppDimens.body,
|
fontSize: 12,
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
height: 1.1,
|
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(
|
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
||||||
items: [
|
items: [
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('discover-nav-home'),
|
tabKey: const Key('discover-nav-home'),
|
||||||
icon: Icons.play_circle_outline_rounded,
|
icon: Icons.home_outlined,
|
||||||
selectedIcon: Icons.play_circle_fill_rounded,
|
selectedIcon: Icons.home_rounded,
|
||||||
label: l10n.homeTab,
|
label: l10n.homeTab,
|
||||||
onTap: () => logic.act(
|
onTap: () => logic.act(
|
||||||
context,
|
context,
|
||||||
@@ -329,8 +637,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
),
|
),
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('discover-nav-my'),
|
tabKey: const Key('discover-nav-my'),
|
||||||
icon: Icons.video_library_outlined,
|
icon: Icons.person_outline_rounded,
|
||||||
selectedIcon: Icons.video_library_rounded,
|
selectedIcon: Icons.person_rounded,
|
||||||
label: l10n.myTab,
|
label: l10n.myTab,
|
||||||
onTap: () => logic.act(
|
onTap: () => logic.act(
|
||||||
context,
|
context,
|
||||||
@@ -343,72 +651,61 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const _coin = AppColors.gold;
|
class _QuickAction extends StatelessWidget {
|
||||||
|
const _QuickAction({
|
||||||
class _BrandMark extends StatelessWidget {
|
super.key,
|
||||||
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({
|
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
|
required this.color,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Material(
|
||||||
color: AppColors.raised,
|
color: AppColors.card,
|
||||||
shape: StadiumBorder(
|
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||||
side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)),
|
clipBehavior: Clip.antiAlias,
|
||||||
),
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const StadiumBorder(),
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: ConstrainedBox(
|
child: Container(
|
||||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
height: 76,
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||||
mainAxisSize: MainAxisSize.min,
|
border: Border.all(color: AppColors.border),
|
||||||
children: [
|
),
|
||||||
Icon(icon, size: 15, color: _coin),
|
child: Column(
|
||||||
const SizedBox(width: 4),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
Text(
|
children: [
|
||||||
label,
|
Container(
|
||||||
maxLines: 1,
|
width: 32,
|
||||||
style: const TextStyle(
|
height: 32,
|
||||||
color: AppColors.cream,
|
decoration: BoxDecoration(
|
||||||
fontSize: AppDimens.caption,
|
color: color.withValues(alpha: 0.15),
|
||||||
fontWeight: FontWeight.w700,
|
borderRadius: BorderRadius.circular(10),
|
||||||
height: 1.1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
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 {
|
String _duration(Duration value) {
|
||||||
const _LoginButton({required this.label, required this.onTap});
|
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
final String label;
|
return '$minutes:$seconds';
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
const _HomeDramaGrid({
|
const _HomeDramaGrid({
|
||||||
required this.l10n,
|
required this.l10n,
|
||||||
required this.categories,
|
required this.categories,
|
||||||
|
required this.dramas,
|
||||||
|
required this.searching,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
final CategoryController categories;
|
final CategoryController categories;
|
||||||
|
final List<Drama> dramas;
|
||||||
|
final bool searching;
|
||||||
final ValueChanged<Drama> onOpen;
|
final ValueChanged<Drama> onOpen;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,16 +38,21 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dramas = categories.visibleDramas;
|
|
||||||
if (dramas.isEmpty) {
|
if (dramas.isEmpty) {
|
||||||
final missingCategory = categories.selectedSectionMissing;
|
final missingCategory = categories.selectedSectionMissing;
|
||||||
return _fill(
|
return _fill(
|
||||||
EmptyState(
|
EmptyState(
|
||||||
key: const Key('home-all-dramas-empty'),
|
key: const Key('home-all-dramas-empty'),
|
||||||
icon: missingCategory
|
icon: searching
|
||||||
|
? Icons.search_off_rounded
|
||||||
|
: missingCategory
|
||||||
? Icons.grid_view_rounded
|
? Icons.grid_view_rounded
|
||||||
: Icons.search_off_rounded,
|
: Icons.movie_filter_outlined,
|
||||||
title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults,
|
title: searching
|
||||||
|
? l10n.noSearchResults
|
||||||
|
: missingCategory
|
||||||
|
? l10n.noCategoryDramas
|
||||||
|
: l10n.noSearchResults,
|
||||||
subtitle: l10n.emptyHint,
|
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'),
|
key: const Key('home-all-dramas-section'),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
|
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
key: const Key('home-all-dramas-grid'),
|
key: const Key('home-all-dramas-grid'),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3,
|
crossAxisCount: 2,
|
||||||
mainAxisSpacing: 14,
|
mainAxisSpacing: 14,
|
||||||
crossAxisSpacing: 10,
|
crossAxisSpacing: 12,
|
||||||
childAspectRatio: 2 / 3,
|
childAspectRatio: 0.55,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _HomeDramaCard(
|
(context, index) => _HomeDramaCard(
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
drama: dramas[index],
|
drama: items[index],
|
||||||
onTap: () => onOpen(dramas[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}'),
|
key: Key('home-drama-card-${drama.id}'),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||||
child: AspectRatio(
|
child: Column(
|
||||||
key: Key('home-drama-cover-${drama.id}'),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
aspectRatio: 2 / 3,
|
children: [
|
||||||
child: ClipRRect(
|
AspectRatio(
|
||||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
key: Key('home-drama-cover-${drama.id}'),
|
||||||
child: Stack(
|
aspectRatio: 3 / 4.2,
|
||||||
fit: StackFit.expand,
|
child: ClipRRect(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||||
RemoteImage(
|
child: Stack(
|
||||||
drama.posterUrl,
|
fit: StackFit.expand,
|
||||||
fit: BoxFit.cover,
|
children: [
|
||||||
showFallbackIcon: true,
|
RemoteImage(
|
||||||
),
|
drama.posterUrl,
|
||||||
const DecoratedBox(
|
fit: BoxFit.cover,
|
||||||
decoration: BoxDecoration(
|
showFallbackIcon: true,
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0x00000000), Color(0xE6100C0A)],
|
|
||||||
stops: [0.42, 1],
|
|
||||||
),
|
),
|
||||||
),
|
const DecoratedBox(
|
||||||
),
|
|
||||||
if (drama.isHot)
|
|
||||||
Positioned(
|
|
||||||
top: 8,
|
|
||||||
left: 8,
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.blue,
|
gradient: LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(6),
|
begin: Alignment.topCenter,
|
||||||
),
|
end: Alignment.bottomCenter,
|
||||||
child: Padding(
|
colors: [
|
||||||
padding: const EdgeInsets.symmetric(
|
Color(0x33000000),
|
||||||
horizontal: 6,
|
Color(0x00000000),
|
||||||
vertical: 2,
|
Color(0xB3000000),
|
||||||
|
],
|
||||||
|
stops: [0, 0.42, 1],
|
||||||
),
|
),
|
||||||
child: Text(
|
),
|
||||||
l10n.hot,
|
),
|
||||||
style: const TextStyle(
|
Positioned(
|
||||||
color: AppColors.onPrimary,
|
top: 8,
|
||||||
fontSize: AppDimens.caption,
|
left: 8,
|
||||||
fontWeight: FontWeight.w800,
|
child: Wrap(
|
||||||
height: 1.1,
|
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>();
|
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
Drama currentDrama;
|
Drama currentDrama;
|
||||||
int tab = 1;
|
int tab = 0;
|
||||||
int searchRequest = 0;
|
int searchRequest = 0;
|
||||||
int sectionRequest = 0;
|
int sectionRequest = 0;
|
||||||
int resumeRequest = 0;
|
int resumeRequest = 0;
|
||||||
|
|||||||
@@ -244,35 +244,30 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
barKey: const Key('home-tabs-bar'),
|
barKey: const Key('home-tabs-bar'),
|
||||||
items: [
|
items: [
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('tab-discover'),
|
tabKey: const Key('tab-home'),
|
||||||
icon: Icons.home_outlined,
|
icon: Icons.home_outlined,
|
||||||
selectedIcon: Icons.home_rounded,
|
selectedIcon: Icons.home_rounded,
|
||||||
label: l10n.homeTab,
|
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,
|
selected: logic.tab == 0,
|
||||||
|
indicatorKey: const Key('tab-home-indicator'),
|
||||||
onTap: () => _selectTab(context, 0),
|
onTap: () => _selectTab(context, 0),
|
||||||
),
|
),
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('tab-my-list'),
|
tabKey: const Key('tab-discover'),
|
||||||
icon: Icons.bookmark_border_rounded,
|
icon: Icons.explore_outlined,
|
||||||
selectedIcon: Icons.bookmark_rounded,
|
selectedIcon: Icons.explore_rounded,
|
||||||
label: l10n.myListTab,
|
label: l10n.discoverTab,
|
||||||
selected: logic.tab == 2 && !logic.profile,
|
selected: logic.tab == 1,
|
||||||
onTap: () => _selectTab(context, 2),
|
indicatorKey: const Key('tab-discover-indicator'),
|
||||||
|
onTap: () => _selectTab(context, 1),
|
||||||
),
|
),
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('tab-my'),
|
tabKey: const Key('tab-my'),
|
||||||
icon: Icons.person_outline_rounded,
|
icon: Icons.person_outline_rounded,
|
||||||
selectedIcon: Icons.person_rounded,
|
selectedIcon: Icons.person_rounded,
|
||||||
label: l10n.myTab,
|
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),
|
onTap: () => _selectTab(context, 2, myProfile: true),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -282,9 +277,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
||||||
final same = tab == 2
|
final same = logic.tab == tab;
|
||||||
? logic.tab == 2 && logic.profile == myProfile
|
|
||||||
: logic.tab == tab;
|
|
||||||
if (!same) {
|
if (!same) {
|
||||||
unawaited(context.read<Haptics>().selectionClick());
|
unawaited(context.read<Haptics>().selectionClick());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class _MyPageState extends State<MyPage> {
|
|||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (!widget.profile) _topBar(l10n),
|
_topBar(l10n),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<Duration?>(
|
child: FutureBuilder<Duration?>(
|
||||||
future: logic.position,
|
future: logic.position,
|
||||||
@@ -132,11 +132,11 @@ class _MyPageState extends State<MyPage> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||||
children: [
|
children: [
|
||||||
if (widget.profile)
|
if (widget.profile) ..._accountPage(l10n),
|
||||||
..._accountPage(l10n)
|
if (widget.profile) const SizedBox(height: 24),
|
||||||
else ...[
|
_library(l10n, position, total, fraction),
|
||||||
_library(l10n, position, total, fraction),
|
if (!widget.profile) const SizedBox(height: 24),
|
||||||
],
|
if (!widget.profile) ..._accountPage(l10n),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -174,7 +174,7 @@ class _MyPageState extends State<MyPage> {
|
|||||||
const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27),
|
const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27),
|
||||||
const SizedBox(width: 7),
|
const SizedBox(width: 7),
|
||||||
Text(
|
Text(
|
||||||
l10n.myListTab,
|
widget.profile ? l10n.myTab : l10n.myListTab,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: AppDimens.pageTitle,
|
fontSize: AppDimens.pageTitle,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
@@ -548,6 +548,7 @@ class _MyPageState extends State<MyPage> {
|
|||||||
selected: logic.section == index,
|
selected: logic.section == index,
|
||||||
button: true,
|
button: true,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
key: Key('mine-library-tab-$index'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
logic.selectSection(index);
|
logic.selectSection(index);
|
||||||
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '../../core/widgets/download_action.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'player_binding.dart';
|
import 'player_binding.dart';
|
||||||
import 'player_logic.dart';
|
import 'player_logic.dart';
|
||||||
|
import 'widgets/home_feed_overlay.dart';
|
||||||
import 'widgets/seek_progress_bar.dart';
|
import 'widgets/seek_progress_bar.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/repositories/playback_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)
|
? (position) => logic.seekTo(context, videoPort!, position)
|
||||||
: null,
|
: 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)
|
else if (!logic.screenCleared)
|
||||||
_PlaybackOverlay(
|
_PlaybackOverlay(
|
||||||
l10n: l10n,
|
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.onScrubStart,
|
||||||
this.onSeek,
|
this.onSeek,
|
||||||
this.showTimes = true,
|
this.showTimes = true,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||||
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
|
|||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final Future<void> Function(Duration)? onSeek;
|
final Future<void> Function(Duration)? onSeek;
|
||||||
final bool showTimes;
|
final bool showTimes;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||||
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: scrubbing ? 48 : 44,
|
height: widget.compact
|
||||||
|
? (scrubbing ? 32 : 20)
|
||||||
|
: (scrubbing ? 48 : 44),
|
||||||
child: SliderTheme(
|
child: SliderTheme(
|
||||||
data: SliderTheme.of(context).copyWith(
|
data: SliderTheme.of(context).copyWith(
|
||||||
trackHeight: scrubbing ? 4 : 3,
|
trackHeight: widget.compact
|
||||||
activeTrackColor: AppColors.gold,
|
? (scrubbing ? 3 : 1.5)
|
||||||
inactiveTrackColor: Colors.white30,
|
: (scrubbing ? 4 : 3),
|
||||||
thumbColor: Colors.white,
|
activeTrackColor: AppColors.blue,
|
||||||
|
inactiveTrackColor: widget.compact
|
||||||
|
? Colors.white.withValues(alpha: 0.18)
|
||||||
|
: Colors.white30,
|
||||||
|
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
||||||
thumbShape: RoundSliderThumbShape(
|
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(
|
child: Semantics(
|
||||||
label: widget.semanticsLabel,
|
label: widget.semanticsLabel,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:fruitshorts/app.dart';
|
|||||||
import 'package:fruitshorts/core/data/models/content_category.dart';
|
import 'package:fruitshorts/core/data/models/content_category.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
@@ -25,7 +26,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'should_show_all_dramas_in_three_column_sliver_grid_without_continue_watching',
|
'should_show_all_dramas_in_two_column_sliver_grid_without_continue_watching',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
tester.view.physicalSize = const Size(390, 844);
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
@@ -44,27 +45,15 @@ void main() {
|
|||||||
tester.getCenter(find.text('All')).dx,
|
tester.getCenter(find.text('All')).dx,
|
||||||
lessThan(tester.getCenter(find.text('Hot')).dx),
|
lessThan(tester.getCenter(find.text('Hot')).dx),
|
||||||
);
|
);
|
||||||
expect(
|
|
||||||
tester
|
|
||||||
.getTopLeft(
|
|
||||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
|
||||||
)
|
|
||||||
.dy -
|
|
||||||
tester
|
|
||||||
.getBottomLeft(find.byKey(const Key('home-category-chips')))
|
|
||||||
.dy,
|
|
||||||
closeTo(14, 0.1),
|
|
||||||
);
|
|
||||||
|
|
||||||
final grid = tester.widget<SliverGrid>(
|
final grid = tester.widget<SliverGrid>(
|
||||||
find.byKey(const Key('home-all-dramas-grid')),
|
find.byKey(const Key('home-all-dramas-grid')),
|
||||||
);
|
);
|
||||||
final delegate =
|
final delegate =
|
||||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||||
expect(delegate.crossAxisCount, 3);
|
expect(delegate.crossAxisCount, 2);
|
||||||
expect(delegate.crossAxisSpacing, 10);
|
expect(delegate.crossAxisSpacing, 12);
|
||||||
expect(delegate.mainAxisSpacing, 14);
|
expect(delegate.mainAxisSpacing, 14);
|
||||||
expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001));
|
expect(delegate.childAspectRatio, closeTo(0.55, 0.001));
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<SliverPadding>(
|
.widget<SliverPadding>(
|
||||||
@@ -80,7 +69,7 @@ void main() {
|
|||||||
final cover = tester.widget<AspectRatio>(
|
final cover = tester.widget<AspectRatio>(
|
||||||
find.byKey(const Key('home-drama-cover-city-lights-demo')),
|
find.byKey(const Key('home-drama-cover-city-lights-demo')),
|
||||||
);
|
);
|
||||||
expect(cover.aspectRatio, closeTo(2 / 3, 0.001));
|
expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001));
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<Text>(
|
.widget<Text>(
|
||||||
@@ -148,15 +137,7 @@ void main() {
|
|||||||
|
|
||||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
||||||
expect(find.text('No matching dramas.'), findsOneWidget);
|
expect(find.text('No matching dramas.'), findsOneWidget);
|
||||||
final content = tester.getCenter(
|
expect(find.byKey(const Key('empty-state-content')), findsOneWidget);
|
||||||
find.byKey(const Key('empty-state-content')),
|
|
||||||
);
|
|
||||||
final chipsBottom = tester
|
|
||||||
.getBottomLeft(find.byKey(const Key('home-category-chips')))
|
|
||||||
.dy;
|
|
||||||
final feed = tester.getRect(find.byType(CustomScrollView));
|
|
||||||
expect(content.dx, closeTo(feed.center.dx, 1));
|
|
||||||
expect(content.dy, closeTo((chipsBottom + feed.bottom) / 2, 36));
|
|
||||||
|
|
||||||
await tester.tap(find.text('Romance'));
|
await tester.tap(find.text('Romance'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -184,7 +165,7 @@ void main() {
|
|||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_fit_three_column_grid_on_compact_screen', (tester) async {
|
testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async {
|
||||||
tester.view.physicalSize = const Size(320, 568);
|
tester.view.physicalSize = const Size(320, 568);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
@@ -204,11 +185,87 @@ void main() {
|
|||||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_filter_trending_grid_when_search_text_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('discover-search')), '');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_continue_watching_only_when_a_record_exists', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(
|
||||||
|
tester,
|
||||||
|
categoryRepository: _GridCategoryRepository(),
|
||||||
|
history: InMemoryWatchHistoryRepository(
|
||||||
|
seed: const [
|
||||||
|
WatchProgress(
|
||||||
|
dramaId: 'city-lights-demo',
|
||||||
|
episodeId: 'city-lights-demo-1',
|
||||||
|
position: Duration(seconds: 42),
|
||||||
|
duration: Duration(minutes: 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-continue-play')), findsOneWidget);
|
||||||
|
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||||
|
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-quick-new')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pumpApp(
|
Future<void> _pumpApp(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
required CategoryRepository categoryRepository,
|
required CategoryRepository categoryRepository,
|
||||||
|
WatchHistoryRepository? history,
|
||||||
}) async {
|
}) async {
|
||||||
final catalog = FakeCatalogRepository();
|
final catalog = FakeCatalogRepository();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -216,7 +273,7 @@ Future<void> _pumpApp(
|
|||||||
playerFactory: createWidgetPlayer,
|
playerFactory: createWidgetPlayer,
|
||||||
catalogRepository: catalog,
|
catalogRepository: catalog,
|
||||||
categoryRepository: categoryRepository,
|
categoryRepository: categoryRepository,
|
||||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
||||||
favoriteRepository: InMemoryFavoriteRepository(),
|
favoriteRepository: InMemoryFavoriteRepository(),
|
||||||
playbackRepository: FakePlaybackRepository(),
|
playbackRepository: FakePlaybackRepository(),
|
||||||
commentRepository: FakeCommentRepository(),
|
commentRepository: FakeCommentRepository(),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import '../support/widget_player.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'should_render_home_feed_banners_when_discover_banners_are_empty',
|
'should_not_mount_carousel_on_discover_even_when_home_banners_exist',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
SharedPreferencesAsyncPlatform.instance =
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
InMemorySharedPreferencesAsync.empty();
|
InMemorySharedPreferencesAsync.empty();
|
||||||
@@ -43,26 +43,11 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(CoverCarousel), findsOneWidget);
|
expect(find.byType(CoverCarousel), findsNothing);
|
||||||
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
|
expect(find.byKey(const Key('discover-search')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
expect(
|
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
|
||||||
find.descendant(
|
expect(find.text('The Signal'), findsWidgets);
|
||||||
of: find.byType(CoverCarousel),
|
|
||||||
matching: find.text('The Signal'),
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 4500));
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
expect(
|
|
||||||
find.descendant(
|
|
||||||
of: find.byType(CoverCarousel),
|
|
||||||
matching: find.text('City Lights & Velvet Nights'),
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,17 +18,20 @@ void main() {
|
|||||||
|
|
||||||
final dramas = [_drama('signal'), _drama('city')];
|
final dramas = [_drama('signal'), _drama('city')];
|
||||||
|
|
||||||
test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () {
|
test(
|
||||||
final logic = HomeLogic(dramas);
|
'should_make_first_catalog_entry_the_current_drama_when_catalog_loads',
|
||||||
|
() {
|
||||||
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
expect(logic.currentDrama.id, 'signal');
|
expect(logic.currentDrama.id, 'signal');
|
||||||
expect(logic.tab, 1);
|
expect(logic.tab, 0);
|
||||||
expect(logic.mySection, 0);
|
expect(logic.mySection, 0);
|
||||||
expect(logic.drawerOpen, isFalse);
|
expect(logic.drawerOpen, isFalse);
|
||||||
expect(logic.profile, isFalse);
|
expect(logic.profile, isFalse);
|
||||||
expect(logic.videoImmersive, isFalse);
|
expect(logic.videoImmersive, isFalse);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should_not_build_shell_when_catalog_is_empty', () {
|
test('should_not_build_shell_when_catalog_is_empty', () {
|
||||||
expect(() => HomeLogic(const []), throwsStateError);
|
expect(() => HomeLogic(const []), throwsStateError);
|
||||||
@@ -82,33 +85,38 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () {
|
test(
|
||||||
final logic = HomeLogic(dramas);
|
'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used',
|
||||||
|
() {
|
||||||
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
logic.openProfile();
|
logic.openProfile();
|
||||||
expect(logic.profile, isTrue);
|
expect(logic.profile, isTrue);
|
||||||
expect(logic.tab, 2);
|
expect(logic.tab, 2);
|
||||||
expect(logic.mySection, 0);
|
expect(logic.mySection, 0);
|
||||||
expect(logic.sectionRequest, 1);
|
expect(logic.sectionRequest, 1);
|
||||||
|
|
||||||
logic.openLibrary(section: 1);
|
logic.openLibrary(section: 1);
|
||||||
expect(logic.profile, isFalse);
|
expect(logic.profile, isFalse);
|
||||||
expect(logic.mySection, 1);
|
expect(logic.mySection, 1);
|
||||||
expect(logic.sectionRequest, 2);
|
expect(logic.sectionRequest, 2);
|
||||||
|
|
||||||
logic.openLibrary();
|
logic.openLibrary();
|
||||||
expect(logic.profile, isFalse);
|
expect(logic.profile, isFalse);
|
||||||
expect(logic.mySection, 1);
|
expect(logic.mySection, 1);
|
||||||
expect(logic.sectionRequest, 3);
|
expect(logic.sectionRequest, 3);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should_return_home_and_open_drawer_when_system_back_is_pressed', () {
|
test('should_return_home_and_open_drawer_when_system_back_is_pressed', () {
|
||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
|
// Home is the launch tab; the first back press lands on Discover.
|
||||||
|
logic.onSystemBack();
|
||||||
|
expect(logic.tab, 1);
|
||||||
logic.onSystemBack();
|
logic.onSystemBack();
|
||||||
expect(logic.drawerOpen, isTrue);
|
expect(logic.drawerOpen, isTrue);
|
||||||
expect(logic.tab, 1);
|
|
||||||
expect(logic.backRequest, 0);
|
expect(logic.backRequest, 0);
|
||||||
logic.onDrawerChanged(false);
|
logic.onDrawerChanged(false);
|
||||||
|
|
||||||
@@ -190,7 +198,9 @@ void main() {
|
|||||||
expect(notifications, 0);
|
expect(notifications, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (tester) async {
|
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
late BuildContext context;
|
late BuildContext context;
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ import '../support/widget_player.dart';
|
|||||||
|
|
||||||
const _keys = [
|
const _keys = [
|
||||||
Key('home-tabs-bar'),
|
Key('home-tabs-bar'),
|
||||||
Key('tab-discover'),
|
|
||||||
Key('tab-home'),
|
Key('tab-home'),
|
||||||
Key('tab-my-list'),
|
Key('tab-discover'),
|
||||||
Key('tab-my'),
|
Key('tab-my'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', (
|
testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final haptics = FakeHaptics();
|
final haptics = FakeHaptics();
|
||||||
@@ -52,10 +51,9 @@ void main() {
|
|||||||
final bar = tester.widget<Container>(barFinder);
|
final bar = tester.widget<Container>(barFinder);
|
||||||
final decoration = bar.decoration as BoxDecoration;
|
final decoration = bar.decoration as BoxDecoration;
|
||||||
final border = decoration.border as Border;
|
final border = decoration.border as Border;
|
||||||
final context = tester.element(barFinder);
|
expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
|
||||||
expect(decoration.color, AppColors.bar);
|
expect(border.top.color, AppColors.border);
|
||||||
expect(border.top.color, Theme.of(context).colorScheme.outlineVariant);
|
expect(border.top.color.a, lessThan(0.2));
|
||||||
expect(border.top.color.computeLuminance(), lessThan(0.2));
|
|
||||||
expect(border.top.color, isNot(Colors.white));
|
expect(border.top.color, isNot(Colors.white));
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
@@ -71,7 +69,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-home')),
|
of: find.byKey(const Key('tab-home')),
|
||||||
matching: find.byIcon(Icons.movie_rounded),
|
matching: find.byIcon(Icons.home_rounded),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
@@ -85,14 +83,14 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-discover')),
|
of: find.byKey(const Key('tab-discover')),
|
||||||
matching: find.byIcon(Icons.home_rounded),
|
matching: find.byIcon(Icons.explore_rounded),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-home')),
|
of: find.byKey(const Key('tab-home')),
|
||||||
matching: find.byIcon(Icons.movie_outlined),
|
matching: find.byIcon(Icons.home_outlined),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
@@ -110,6 +108,32 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('tab-my-list')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('tab-discover')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('tab-my')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Viewer'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('mine-library-tab-0'), skipOffstage: false),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
|
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -119,6 +143,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
haptics.reset();
|
haptics.reset();
|
||||||
|
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('App Language'));
|
await tester.tap(find.text('App Language'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|||||||
+57
-19
@@ -117,7 +117,12 @@ void main() {
|
|||||||
expect(find.text('Following'), findsNothing);
|
expect(find.text('Following'), findsNothing);
|
||||||
expect(find.byTooltip('Episodes'), findsWidgets);
|
expect(find.byTooltip('Episodes'), findsWidgets);
|
||||||
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
||||||
expect(find.byTooltip('Picture in picture'), findsOneWidget);
|
expect(find.byTooltip('Picture in picture'), findsNothing);
|
||||||
|
await tester.tap(find.byTooltip('More'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Picture in picture'), findsOneWidget);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Watch Full'), findsNothing);
|
expect(find.text('Watch Full'), findsNothing);
|
||||||
expect(find.byTooltip('Explore series'), findsNothing);
|
expect(find.byTooltip('Explore series'), findsNothing);
|
||||||
});
|
});
|
||||||
@@ -155,6 +160,8 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -217,15 +224,18 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Vicky sheng'), findsOneWidget);
|
expect(find.text('Vicky sheng'), findsOneWidget);
|
||||||
expect(find.text('Coins'), findsOneWidget);
|
expect(find.text('Coins'), findsOneWidget);
|
||||||
expect(find.text('PREFERENCES'), findsOneWidget);
|
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('App Language'));
|
await tester.tap(find.text('App Language'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('简体中文').last);
|
await tester.tap(find.text('简体中文').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('我的'), findsWidgets);
|
expect(find.text('我的'), findsWidgets);
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, 700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('我的账户'), findsOneWidget);
|
expect(find.text('我的账户'), findsOneWidget);
|
||||||
expect(find.text('首页'), findsOneWidget);
|
expect(find.text('首页'), findsOneWidget);
|
||||||
await tester.tap(find.text('首页'));
|
await tester.tap(find.text('找剧'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('全部'), findsOneWidget);
|
expect(find.text('全部'), findsOneWidget);
|
||||||
expect(find.text('热门'), findsOneWidget);
|
expect(find.text('热门'), findsOneWidget);
|
||||||
@@ -272,7 +282,7 @@ void main() {
|
|||||||
expect(find.text('Vicky sheng'), findsOneWidget);
|
expect(find.text('Vicky sheng'), findsOneWidget);
|
||||||
expect(find.text('Coins'), findsOneWidget);
|
expect(find.text('Coins'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
await tester.tap(find.text('Shorts'));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
},
|
},
|
||||||
@@ -311,25 +321,39 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Home'));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('All'), findsOneWidget);
|
expect(find.text('All'), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
expect(find.text('Rewards'), findsOneWidget);
|
expect(find.byKey(const Key('discover-profile')), findsOneWidget);
|
||||||
expect(find.text(r'$ 0'), findsOneWidget);
|
|
||||||
expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
|
|
||||||
expect(
|
expect(
|
||||||
find.text('Action · Thriller', skipOffstage: false),
|
find.byKey(
|
||||||
|
const Key('home-drama-genre-city-lights-demo'),
|
||||||
|
skipOffstage: false,
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(
|
||||||
|
const Key('home-drama-genre-vengeance-demo'),
|
||||||
|
skipOffstage: false,
|
||||||
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.byTooltip('Search'), findsNothing);
|
expect(find.byTooltip('Search'), findsNothing);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.byKey(const Key('mine-library-tab-1')),
|
||||||
|
260,
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||||
await tester.tap(find.text('Shorts'));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
},
|
},
|
||||||
@@ -345,7 +369,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Home'));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
expect(find.byTooltip('Search'), findsNothing);
|
expect(find.byTooltip('Search'), findsNothing);
|
||||||
@@ -356,14 +380,16 @@ void main() {
|
|||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('History'), findsOneWidget);
|
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('History'), findsWidgets);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(tester.element(find.text('History'))).canPop(),
|
Navigator.of(tester.element(find.text('History').first)).canPop(),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Watchlist'));
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||||
|
|
||||||
@@ -377,7 +403,11 @@ void main() {
|
|||||||
).canPop(),
|
).canPop(),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('tab-home')));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
@@ -545,6 +575,8 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
|
await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
@@ -570,6 +602,8 @@ void main() {
|
|||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -608,9 +642,13 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('rail-save')));
|
await tester.tap(find.byKey(const Key('rail-save')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Watchlist'));
|
await tester.scrollUntilVisible(
|
||||||
|
find.byKey(const Key('mine-library-tab-1')),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||||
|
|||||||
Reference in new issue
Block a user