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

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

No files matched your search

+43 -26
View File
@@ -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,
), ),
), ),
], ],
+4
View File
@@ -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",
+4
View File
@@ -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",
+24
View File
@@ -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:
+12
View File
@@ -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';
+12
View File
@@ -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';
+12
View File
@@ -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 => '海报展示';
+4
View File
@@ -23,6 +23,10 @@
"newReleases": "新剧", "newReleases": "新剧",
"trendingMicroDramas": "热门短剧", "trendingMicroDramas": "热门短剧",
"seeAll": "查看全部", "seeAll": "查看全部",
"trendingSubtitle": "本周追看最多",
"verticalMiniSeries": "竖屏短剧",
"followSeries": "追剧",
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
"artworkOnly": "海报展示", "artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供", "videoPending": "视频待提供",
+1 -4
View File
@@ -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,
);
} }
+40
View File
@@ -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() {
+448 -171
View File
@@ -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),
);
}
} }
+145 -90
View File
@@ -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';
}
+1 -1
View File
@@ -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;
+12 -19
View File
@@ -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());
} }
+8 -7
View File
@@ -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);
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
+52
View File
@@ -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,
+85 -28
View File
@@ -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(),
+6 -21
View File
@@ -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());
}, },
); );
+39 -29
View File
@@ -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(
+36 -10
View File
@@ -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
View File
@@ -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);