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
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
@@ -14,6 +16,7 @@ class AppBottomNavItem {
required this.label,
required this.onTap,
this.selected = false,
this.indicatorKey,
});
final Key tabKey;
@@ -22,9 +25,10 @@ class AppBottomNavItem {
final String label;
final VoidCallback onTap;
final bool selected;
final Key? indicatorKey;
}
/// Shared bottom navigation used by all root tabs and the player fallback.
/// Shared 3-tab navigation used by all root tabs and the player fallback.
class AppBottomNav extends StatelessWidget {
const AppBottomNav({
super.key,
@@ -38,20 +42,23 @@ class AppBottomNav extends StatelessWidget {
final Key? barKey;
@override
Widget build(BuildContext context) => SafeArea(
top: false,
child: Container(
key: barKey,
height: AppDimens.navHeight,
decoration: BoxDecoration(
color: backgroundColor,
border: const Border(
top: BorderSide(color: AppColors.outlineVariantDark),
Widget build(BuildContext context) => ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: SafeArea(
top: false,
child: Container(
key: barKey,
height: AppDimens.navHeight,
decoration: BoxDecoration(
color: backgroundColor.withValues(alpha: 0.95),
border: const Border(top: BorderSide(color: AppColors.border)),
),
child: Row(
children: [for (final item in items) _NavDestination(item: item)],
),
),
),
child: Row(
children: [for (final item in items) _NavDestination(item: item)],
),
),
);
}
@@ -71,28 +78,23 @@ class _NavDestination extends StatelessWidget {
key: item.tabKey,
onTap: item.onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
constraints: const BoxConstraints(minHeight: 48),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
AnimatedScale(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 3,
),
decoration: BoxDecoration(
color: item.selected ? AppColors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
scale: item.selected ? 1 : 0.96,
child: Icon(
item.selected ? item.selectedIcon : item.icon,
size: AppDimens.navIcon,
color: item.selected ? AppColors.onPrimary : AppColors.muted,
color: item.selected
? AppColors.blue
: Colors.white.withValues(alpha: 0.45),
),
),
const SizedBox(height: 2),
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
@@ -100,7 +102,22 @@ class _NavDestination extends StatelessWidget {
style: TextStyle(
fontSize: AppDimens.navLabel,
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
color: item.selected ? AppColors.blueSoft : AppColors.muted,
color: item.selected
? AppColors.blue
: Colors.white.withValues(alpha: 0.45),
letterSpacing: 0,
),
),
const SizedBox(height: 3),
AnimatedContainer(
key: item.indicatorKey,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
width: AppDimens.navDot,
height: AppDimens.navDot,
decoration: BoxDecoration(
color: item.selected ? AppColors.blue : Colors.transparent,
shape: BoxShape.circle,
),
),
],
+4
View File
@@ -23,6 +23,10 @@
"newReleases": "New Releases",
"trendingMicroDramas": "Trending Micro-Dramas",
"seeAll": "See All",
"trendingSubtitle": "Most binge-watched this week",
"verticalMiniSeries": "VERTICAL MINI-SERIES",
"followSeries": "Follow Series",
"seasonOneVerticalCinema": "Season 1 • Vertical Cinema",
"artworkOnly": "Artwork",
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
"videoPending": "Video pending",
+4
View File
@@ -23,6 +23,10 @@
"newReleases": "Rilisan Baru",
"trendingMicroDramas": "Drama Mini Populer",
"seeAll": "Lihat Semua",
"trendingSubtitle": "Paling banyak ditonton minggu ini",
"verticalMiniSeries": "SERIAL MINI VERTIKAL",
"followSeries": "Ikuti Series",
"seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal",
"artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap",
+24
View File
@@ -238,6 +238,30 @@ abstract class AppLocalizations {
/// **'See All'**
String get seeAll;
/// No description provided for @trendingSubtitle.
///
/// In en, this message translates to:
/// **'Most binge-watched this week'**
String get trendingSubtitle;
/// No description provided for @verticalMiniSeries.
///
/// In en, this message translates to:
/// **'VERTICAL MINI-SERIES'**
String get verticalMiniSeries;
/// No description provided for @followSeries.
///
/// In en, this message translates to:
/// **'Follow Series'**
String get followSeries;
/// No description provided for @seasonOneVerticalCinema.
///
/// In en, this message translates to:
/// **'Season 1 • Vertical Cinema'**
String get seasonOneVerticalCinema;
/// No description provided for @artworkOnly.
///
/// In en, this message translates to:
+12
View File
@@ -78,6 +78,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get seeAll => 'See All';
@override
String get trendingSubtitle => 'Most binge-watched this week';
@override
String get verticalMiniSeries => 'VERTICAL MINI-SERIES';
@override
String get followSeries => 'Follow Series';
@override
String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema';
@override
String get artworkOnly => 'Artwork';
+12
View File
@@ -78,6 +78,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get seeAll => 'Lihat Semua';
@override
String get trendingSubtitle => 'Paling banyak ditonton minggu ini';
@override
String get verticalMiniSeries => 'SERIAL MINI VERTIKAL';
@override
String get followSeries => 'Ikuti Series';
@override
String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal';
@override
String get artworkOnly => 'Gambar';
+12
View File
@@ -77,6 +77,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get seeAll => '查看全部';
@override
String get trendingSubtitle => '本周追看最多';
@override
String get verticalMiniSeries => '竖屏短剧';
@override
String get followSeries => '追剧';
@override
String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院';
@override
String get artworkOnly => '海报展示';
+4
View File
@@ -23,6 +23,10 @@
"newReleases": "新剧",
"trendingMicroDramas": "热门短剧",
"seeAll": "查看全部",
"trendingSubtitle": "本周追看最多",
"verticalMiniSeries": "竖屏短剧",
"followSeries": "追剧",
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
"artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供",
+1 -4
View File
@@ -8,8 +8,5 @@ class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
required WatchHistoryRepository history,
}) => DiscoverLogic(
drama: drama,
history: history,
);
}) => DiscoverLogic(drama: drama, history: history);
}
+40
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart';
@@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier {
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
WatchProgress? resumeRecord;
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
@@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier {
.resumePointFor(drama.id)
.then((point) => point?.position)
.catchError((Object _) => null);
refreshResume();
}
Future<void> refreshResume() async {
try {
final recent = await history.recent(limit: 1);
if (recent.isEmpty || recent.first.finished) {
resumeRecord = null;
} else {
resumeRecord = recent.first;
}
} catch (_) {
resumeRecord = null;
}
notify();
}
void onSearchChanged() => notify();
List<Drama> filtered(List<Drama> dramas) {
final query = search.text.trim().toLowerCase();
if (query.isEmpty) return dramas;
return dramas.where((drama) {
final haystack = [
drama.title,
drama.genre,
drama.category,
drama.synopsis,
].join(' ').toLowerCase();
return haystack.contains(query);
}).toList();
}
Drama? resumeDrama(List<Drama> dramas) {
final record = resumeRecord;
if (record == null) return null;
for (final drama in dramas) {
if (drama.id == record.dramaId) return drama;
}
return null;
}
bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() {
+448 -171
View File
@@ -5,20 +5,19 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/glass_panel.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
import 'widgets/cover_carousel.dart';
part 'widgets/home_drama_grid.dart';
@@ -70,7 +69,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
drama: widget.drama,
history: widget.history ?? context.read<WatchHistoryRepository>(),
);
if (widget.focusSearch) logic.searchOpen = true;
if (widget.focusSearch) {
logic.searchOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
}
}
@override
@@ -104,14 +108,13 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>();
final homeBanners = context.watch<CatalogController>().feed.banners;
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final visible = logic.filtered(categories.visibleDramas);
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_header(l10n),
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
Expanded(child: _feed(l10n, categories, allDramas, visible)),
if (!widget.embedded) _bottomNav(l10n),
],
),
@@ -130,29 +133,96 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _feed(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
List<Drama> allDramas,
List<Drama> visible,
) => CustomScrollView(
controller: logic.scroll,
slivers: [
SliverToBoxAdapter(child: _header(l10n)),
SliverToBoxAdapter(child: _search()),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _carousel(l10n, banners, allDramas),
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
child: _categories(l10n, categories),
),
),
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _continueCard(
l10n,
logic.resumeDrama(allDramas)!,
logic.resumeRecord!,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _quickActions(l10n, categories),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
child: _categories(l10n, categories),
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.trendingMicroDramas,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
l10n.trendingSubtitle,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
),
),
],
),
),
TextButton(
onPressed: () =>
categories.selectCode(CategoryController.hotCode),
style: TextButton.styleFrom(
foregroundColor: AppColors.blue,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.seeAll,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Icon(Icons.chevron_right_rounded, size: 16),
],
),
),
],
),
),
),
_HomeDramaGrid(
l10n: l10n,
categories: categories,
dramas: visible,
searching: logic.search.text.trim().isNotEmpty,
onOpen: (drama) => logic.openCard(
context,
drama,
@@ -166,93 +236,129 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _header(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
child: Row(
children: [
const _BrandMark(),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 20,
fontWeight: FontWeight.w800,
height: 1.1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 21,
fontWeight: FontWeight.w700,
height: 1.05,
letterSpacing: 0,
),
),
),
],
),
const SizedBox(height: 3),
Text(
l10n.verticalMiniSeries,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10,
fontWeight: FontWeight.w500,
letterSpacing: 0.6,
),
),
],
),
),
const SizedBox(width: 12),
Semantics(
button: true,
label: widget.signedIn ? l10n.myTab : l10n.logIn,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.raised,
border: Border.all(
color: AppColors.secondary.withValues(alpha: 0.7),
width: 2,
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 21,
),
),
),
),
const SizedBox(width: 8),
if (widget.signedIn) ...[
_HeaderPill(
icon: Icons.monetization_on_rounded,
label: r'$ 0',
onTap: widget.onCoins,
),
const SizedBox(width: 8),
_HeaderPill(
icon: Icons.card_giftcard_rounded,
label: l10n.rewards,
onTap: widget.onRewards,
),
] else
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
],
),
);
}
Widget _carousel(
AppLocalizations l10n,
List<FeedBanner> banners,
List<Drama> allDramas,
) {
final items = banners
.where((banner) => banner.imageUrl.isNotEmpty)
.toList();
if (items.isEmpty) return const SizedBox.shrink();
return CoverCarousel(
active: widget.active,
items: [
for (final banner in items)
CoverItem(
title: _bannerTitle(banner, allDramas),
subtitle: '',
image: banner.imageUrl,
onOpen: () => _openBanner(banner, allDramas),
Widget _search() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: AppDimens.searchHeight,
child: TextField(
key: const Key('discover-search'),
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.card,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 20,
),
suffixIcon: const Icon(
Icons.mic_none_rounded,
color: AppColors.muted,
size: 19,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
borderSide: const BorderSide(color: AppColors.blue, width: 1.2),
),
),
],
),
),
);
}
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return '';
for (final drama in allDramas) {
if (drama.id == dramaId) return drama.title;
}
return '';
}
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return;
for (final drama in allDramas) {
if (drama.id == dramaId) {
logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
);
return;
}
}
}
Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.all),
@@ -265,6 +371,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
key: const Key('home-category-chips'),
height: AppDimens.tapTarget,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
@@ -281,17 +388,22 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14),
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color: selected ? AppColors.blue : AppColors.raised,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
color: selected ? AppColors.blue : AppColors.card,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected
? AppColors.blue
: Colors.white.withValues(alpha: 0.08),
),
),
child: Text(
choice.label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.cream,
fontSize: AppDimens.body,
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1,
),
@@ -305,12 +417,208 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
Widget _continueCard(
AppLocalizations l10n,
Drama drama,
WatchProgress record,
) {
final progress = record.fraction;
return GlassPanel(
key: const Key('discover-continue-watching'),
radius: AppDimens.glassSmallRadius,
blur: 18,
padding: const EdgeInsets.all(12),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)],
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 64,
height: 88,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Flexible(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.blue,
fontSize: 9.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
),
],
),
const SizedBox(height: 3),
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
_resumeMeta(drama, record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10.5,
),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progress <= 0 ? null : progress,
minHeight: 3,
backgroundColor: Colors.white.withValues(alpha: 0.12),
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
],
),
),
const SizedBox(width: 10),
FilledButton(
key: const Key('discover-continue-play'),
onPressed: () => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(64, 34),
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.play),
const SizedBox(width: 2),
const Icon(Icons.play_arrow_rounded, size: 14),
],
),
),
],
),
);
}
String _resumeMeta(Drama drama, WatchProgress record) {
final label = AppLocalizations.of(context)!;
Episode? episode;
for (final item in drama.episodes) {
if (item.id == record.episodeId) {
episode = item;
break;
}
}
final head = episode == null
? ''
: '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · ';
return '$head${_duration(record.position)} / ${_duration(record.duration)}';
}
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
return Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('discover-quick-filter'),
icon: Icons.tune_rounded,
label: l10n.filter,
color: AppColors.secondary,
onTap: logic.openSearch,
),
),
const SizedBox(width: 8),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-charts'),
icon: Icons.local_fire_department_rounded,
label: l10n.topCharts,
color: AppColors.blue,
onTap: () => categories.selectCode(CategoryController.hotCode),
),
),
const SizedBox(width: 8),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-new'),
icon: Icons.auto_awesome_rounded,
label: l10n.newest,
color: AppColors.gold,
onTap: () => categories.selectCode(CategoryController.newCode),
),
),
const SizedBox(width: 8),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
color: AppColors.success,
onTap: () => logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
),
],
);
}
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('discover-nav-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
onTap: () => logic.act(
context,
@@ -329,8 +637,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
),
AppBottomNavItem(
tabKey: const Key('discover-nav-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
onTap: () => logic.act(
context,
@@ -343,72 +651,61 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
const _coin = AppColors.gold;
class _BrandMark extends StatelessWidget {
const _BrandMark();
@override
Widget build(BuildContext context) {
return Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 20,
),
);
}
}
class _HeaderPill extends StatelessWidget {
const _HeaderPill({
class _QuickAction extends StatelessWidget {
const _QuickAction({
super.key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.raised,
shape: StadiumBorder(
side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)),
),
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: _coin),
const SizedBox(width: 4),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.cream,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w700,
height: 1.1,
),
child: Container(
height: 76,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
],
),
child: Icon(icon, color: color, size: 17),
),
const SizedBox(height: 5),
Text(
label,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 9.5,
fontWeight: FontWeight.w500,
height: 1.05,
),
),
],
),
),
),
@@ -416,28 +713,8 @@ class _HeaderPill extends StatelessWidget {
}
}
class _LoginButton extends StatelessWidget {
const _LoginButton({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.blue,
padding: const EdgeInsets.symmetric(horizontal: 14),
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontSize: AppDimens.body,
fontWeight: FontWeight.w600,
),
),
child: Text(label, maxLines: 1),
);
}
String _duration(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
+145 -90
View File
@@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget {
const _HomeDramaGrid({
required this.l10n,
required this.categories,
required this.dramas,
required this.searching,
required this.onOpen,
});
final AppLocalizations l10n;
final CategoryController categories;
final List<Drama> dramas;
final bool searching;
final ValueChanged<Drama> onOpen;
@override
@@ -34,16 +38,21 @@ class _HomeDramaGrid extends StatelessWidget {
);
}
final dramas = categories.visibleDramas;
if (dramas.isEmpty) {
final missingCategory = categories.selectedSectionMissing;
return _fill(
EmptyState(
key: const Key('home-all-dramas-empty'),
icon: missingCategory
icon: searching
? Icons.search_off_rounded
: missingCategory
? Icons.grid_view_rounded
: Icons.search_off_rounded,
title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults,
: Icons.movie_filter_outlined,
title: searching
? l10n.noSearchResults
: missingCategory
? l10n.noCategoryDramas
: l10n.noSearchResults,
subtitle: l10n.emptyHint,
),
);
@@ -57,24 +66,24 @@ class _HomeDramaGrid extends StatelessWidget {
);
}
Widget _dramaGrid(List<Drama> dramas) => SliverPadding(
Widget _dramaGrid(List<Drama> items) => SliverPadding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
sliver: SliverGrid(
key: const Key('home-all-dramas-grid'),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisCount: 2,
mainAxisSpacing: 14,
crossAxisSpacing: 10,
childAspectRatio: 2 / 3,
crossAxisSpacing: 12,
childAspectRatio: 0.55,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _HomeDramaCard(
l10n: l10n,
drama: dramas[index],
onTap: () => onOpen(dramas[index]),
drama: items[index],
onTap: () => onOpen(items[index]),
),
childCount: dramas.length,
childCount: items.length,
),
),
);
@@ -102,95 +111,141 @@ class _HomeDramaCard extends StatelessWidget {
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
child: AspectRatio(
key: Key('home-drama-cover-${drama.id}'),
aspectRatio: 2 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xE6100C0A)],
stops: [0.42, 1],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
key: Key('home-drama-cover-${drama.id}'),
aspectRatio: 3 / 4.2,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
if (drama.isHot)
Positioned(
top: 8,
left: 8,
child: DecoratedBox(
const DecoratedBox(
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x33000000),
Color(0x00000000),
Color(0xB3000000),
],
stops: [0, 0.42, 1],
),
child: Text(
l10n.hot,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w800,
height: 1.1,
),
),
Positioned(
top: 8,
left: 8,
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (drama.isHot) _badge(l10n.hot, AppColors.blue),
if (drama.freeEpisodes > 0)
_badge(l10n.free, AppColors.success),
],
),
),
if (drama.viewCount > 0)
Positioned(
right: 8,
bottom: 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
child: Text(
_compactCount(drama.viewCount),
key: Key('home-drama-views-${drama.id}'),
style: const TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
Positioned(
left: 10,
right: 10,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: AppDimens.body,
fontWeight: FontWeight.w800,
height: 1.15,
),
),
const SizedBox(height: 4),
Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
],
),
],
),
],
),
),
const SizedBox(height: 8),
Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 3),
Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
],
),
);
}
Widget _badge(String label, Color color) {
return DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
child: Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
height: 1.1,
),
),
),
);
}
}
String _compactCount(int value) {
if (value >= 1000000) {
final scaled = value / 1000000;
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M';
}
if (value >= 1000) {
final scaled = value / 1000;
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K';
}
return '$value';
}
+1 -1
View File
@@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier {
final scaffoldKey = GlobalKey<ScaffoldState>();
Drama currentDrama;
int tab = 1;
int tab = 0;
int searchRequest = 0;
int sectionRequest = 0;
int resumeRequest = 0;
+12 -19
View File
@@ -244,35 +244,30 @@ class _HomeTabsState extends State<_HomeTabs> {
barKey: const Key('home-tabs-bar'),
items: [
AppBottomNavItem(
tabKey: const Key('tab-discover'),
tabKey: const Key('tab-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
selected: logic.tab == 1,
onTap: () => _selectTab(context, 1),
),
AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.movie_outlined,
selectedIcon: Icons.movie_rounded,
label: l10n.shortsTab,
selected: logic.tab == 0,
indicatorKey: const Key('tab-home-indicator'),
onTap: () => _selectTab(context, 0),
),
AppBottomNavItem(
tabKey: const Key('tab-my-list'),
icon: Icons.bookmark_border_rounded,
selectedIcon: Icons.bookmark_rounded,
label: l10n.myListTab,
selected: logic.tab == 2 && !logic.profile,
onTap: () => _selectTab(context, 2),
tabKey: const Key('tab-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
selected: logic.tab == 1,
indicatorKey: const Key('tab-discover-indicator'),
onTap: () => _selectTab(context, 1),
),
AppBottomNavItem(
tabKey: const Key('tab-my'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
selected: logic.tab == 2 && logic.profile,
selected: logic.tab == 2,
indicatorKey: const Key('tab-my-indicator'),
onTap: () => _selectTab(context, 2, myProfile: true),
),
],
@@ -282,9 +277,7 @@ class _HomeTabsState extends State<_HomeTabs> {
}
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
final same = tab == 2
? logic.tab == 2 && logic.profile == myProfile
: logic.tab == tab;
final same = logic.tab == tab;
if (!same) {
unawaited(context.read<Haptics>().selectionClick());
}
+8 -7
View File
@@ -115,7 +115,7 @@ class _MyPageState extends State<MyPage> {
bottom: !widget.embedded,
child: Column(
children: [
if (!widget.profile) _topBar(l10n),
_topBar(l10n),
Expanded(
child: FutureBuilder<Duration?>(
future: logic.position,
@@ -132,11 +132,11 @@ class _MyPageState extends State<MyPage> {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: [
if (widget.profile)
..._accountPage(l10n)
else ...[
_library(l10n, position, total, fraction),
],
if (widget.profile) ..._accountPage(l10n),
if (widget.profile) const SizedBox(height: 24),
_library(l10n, position, total, fraction),
if (!widget.profile) const SizedBox(height: 24),
if (!widget.profile) ..._accountPage(l10n),
const SizedBox(height: 24),
Center(
child: Text(
@@ -174,7 +174,7 @@ class _MyPageState extends State<MyPage> {
const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27),
const SizedBox(width: 7),
Text(
l10n.myListTab,
widget.profile ? l10n.myTab : l10n.myListTab,
style: const TextStyle(
fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800,
@@ -548,6 +548,7 @@ class _MyPageState extends State<MyPage> {
selected: logic.section == index,
button: true,
child: InkWell(
key: Key('mine-library-tab-$index'),
onTap: () {
logic.selectSection(index);
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
+52
View File
@@ -24,6 +24,7 @@ import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
@@ -334,6 +335,57 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
? (position) => logic.seekTo(context, videoPort!, position)
: null,
)
else if (!logic.screenCleared && widget.homeMode)
HomeFeedOverlay(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteToggleFailed)),
);
}
},
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onEpisodes: () => logic.showEpisodes(context),
onDownload: () => requestEpisodeDownload(context, episode.id),
onClear: () => logic.setScreenCleared(true),
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
)
else if (!logic.screenCleared)
_PlaybackOverlay(
l10n: l10n,
@@ -0,0 +1,670 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_panel.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import 'seek_progress_bar.dart';
/// Immersive home layer from `screens/01-home.html`.
///
/// Playback, ticket, progress, favorite, like, comment, and share callbacks are
/// all owned by [PlayerPage]; this widget only paints the design overlay.
class HomeFeedOverlay extends StatelessWidget {
const HomeFeedOverlay({
super.key,
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.saved,
required this.onLike,
required this.onSave,
required this.onComments,
required this.onShare,
required this.onEpisodes,
required this.onDownload,
required this.onClear,
required this.onSearch,
required this.onScrubStart,
required this.onSeek,
this.onPip,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final VideoPlayerController? controller;
final bool liked;
final bool saved;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onComments;
final VoidCallback onShare;
final VoidCallback onEpisodes;
final VoidCallback onDownload;
final VoidCallback onClear;
final VoidCallback onSearch;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override
Widget build(BuildContext context) {
final compact = MediaQuery.sizeOf(context).height < 620;
return SafeArea(
bottom: false,
child: Column(
children: [
_header(context),
Expanded(
child: Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 12, 4),
child: _rail(compact),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: _infoCard(context, compact),
),
],
),
);
}
Widget _header(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 7),
Flexible(
child: Text(
localizedDramaTitle(l10n, drama),
key: const Key('home-feed-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 17,
fontWeight: FontWeight.w700,
height: 1.1,
shadows: [
Shadow(blurRadius: 10, color: Colors.black87),
],
),
),
),
if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()],
],
),
const SizedBox(height: 3),
Text(
'${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.64),
fontSize: 10.5,
fontWeight: FontWeight.w500,
shadows: const [
Shadow(blurRadius: 8, color: Colors.black87),
],
),
),
],
),
),
const SizedBox(width: 6),
_GlassIconButton(
tooltip: l10n.search,
icon: Icons.search_rounded,
onPressed: onSearch,
),
const SizedBox(width: 4),
_GlassIconButton(
tooltip: l10n.clearScreen,
icon: Icons.fit_screen_outlined,
onPressed: onClear,
),
const SizedBox(width: 4),
_GlassIconButton(
tooltip: l10n.more,
icon: Icons.more_horiz_rounded,
onPressed: () => _openMore(context),
),
],
),
);
}
Widget _hotBadge() {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
child: Text(
l10n.hot,
style: const TextStyle(
color: AppColors.blue,
fontSize: 8,
fontWeight: FontWeight.w800,
letterSpacing: 0.4,
height: 1.1,
),
),
),
);
}
Widget _rail(bool compact) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
button: true,
label: saved ? l10n.followingTab : l10n.followSeries,
child: InkWell(
key: const Key('home-feed-follow'),
onTap: onSave,
customBorder: const CircleBorder(),
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: compact ? 40 : 44,
height: compact ? 40 : 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.blue, width: 2),
color: AppColors.raised,
),
child: ClipOval(
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
Positioned(
bottom: -4,
left: 14,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: saved ? AppColors.success : AppColors.blue,
shape: BoxShape.circle,
border: Border.all(color: AppColors.ink, width: 2),
),
child: Icon(
saved ? Icons.check_rounded : Icons.add_rounded,
color: AppColors.onPrimary,
size: 11,
),
),
),
],
),
),
),
const SizedBox(height: 8),
_RailAction(
compact: compact,
key: const Key('rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: l10n.like,
selected: liked,
onTap: onLike,
),
_RailAction(
compact: compact,
key: const Key('rail-save'),
icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
label: l10n.save,
selected: saved,
onTap: onSave,
),
_RailAction(
compact: compact,
key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onTap: onComments,
),
_RailAction(
compact: compact,
key: const Key('rail-share'),
icon: Icons.share_outlined,
label: l10n.share,
onTap: onShare,
),
],
);
}
Widget _infoCard(BuildContext context, bool compact) {
final genres = [
if (drama.genre.isNotEmpty) drama.genre,
if (drama.category.isNotEmpty) drama.category,
].take(2).toList();
return GlassPanel(
key: const Key('home-feed-info-card'),
radius: 26,
blur: 24,
padding: const EdgeInsets.fromLTRB(16, 13, 16, 12),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0x260B0C10), Color(0x590B0C10)],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Wrap(
spacing: 5,
runSpacing: 4,
children: [
for (var i = 0; i < genres.length; i++)
_genreChip(genres[i], i.isEven),
],
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 5,
height: 5,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 4),
Text(
'${l10n.episodeLabel(episode.number)} / '
'${drama.episodeCount}',
key: const Key('home-feed-episode'),
style: const TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 8),
Text(
drama.synopsis,
maxLines: compact ? 1 : 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 12,
height: 1.35,
),
),
const SizedBox(height: 6),
SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
showTimes: false,
compact: true,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
_timeRow(),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: SizedBox(
height: 40,
child: FilledButton.icon(
key: const Key('home-feed-follow-series'),
onPressed: onSave,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
icon: Icon(
saved ? Icons.check_rounded : Icons.add_rounded,
size: 16,
),
label: Text(
saved ? l10n.followingTab : l10n.followSeries,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
const SizedBox(width: 8),
_GlassAction(
tooltip: l10n.episodes,
icon: Icons.grid_view_rounded,
label: l10n.episodes,
onPressed: onEpisodes,
),
],
),
],
),
);
}
Widget _genreChip(String label, bool secondary) {
final color = secondary ? AppColors.secondary : AppColors.blue;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
);
}
Widget _timeRow() {
final source = controller;
if (source == null) {
return const _Times(position: Duration.zero, duration: Duration.zero);
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) =>
_Times(position: value.position, duration: value.duration),
);
}
Future<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('home-feed-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.blueSoft,
),
title: Text(l10n.downloads),
onTap: () {
Navigator.pop(sheetContext);
onDownload();
},
),
if (onPip != null)
ListTile(
key: const Key('home-feed-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.blueSoft,
),
title: Text(l10n.pictureInPicture),
onTap: () {
Navigator.pop(sheetContext);
onPip!();
},
),
ListTile(
leading: const Icon(
Icons.fit_screen_outlined,
color: AppColors.blueSoft,
),
title: Text(l10n.clearScreen),
onTap: () {
Navigator.pop(sheetContext);
onClear();
},
),
],
),
),
);
}
}
class _Times extends StatelessWidget {
const _Times({required this.position, required this.duration});
final Duration position;
final Duration duration;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(position),
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
Text(
_format(duration),
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
],
),
);
}
}
class _GlassIconButton extends StatelessWidget {
const _GlassIconButton({
required this.tooltip,
required this.icon,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 34,
height: 34,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.28),
foregroundColor: AppColors.onSurface,
side: BorderSide(color: Colors.white.withValues(alpha: 0.16)),
),
icon: Icon(icon, size: 18),
),
);
}
}
class _GlassAction extends StatelessWidget {
const _GlassAction({
required this.tooltip,
required this.icon,
required this.label,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(999),
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: AppColors.secondary, size: 15),
const SizedBox(width: 5),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.cream,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
class _RailAction extends StatelessWidget {
const _RailAction({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
this.compact = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
final bool compact;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 2 : 3),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GlassPanel(
radius: 999,
blur: 16,
child: SizedBox(
width: compact ? 40 : 38,
height: compact ? 40 : 38,
child: IconButton(
tooltip: label,
onPressed: onTap,
padding: EdgeInsets.zero,
icon: Icon(
icon,
size: 19,
color: selected ? AppColors.blue : AppColors.onSurface,
),
),
),
),
if (!compact) ...[
const SizedBox(height: 2),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.cream,
fontSize: 9,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
],
],
),
);
}
}
String _format(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
@@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget {
this.onScrubStart,
this.onSeek,
this.showTimes = true,
this.compact = false,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showTimes;
final bool compact;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
),
),
SizedBox(
height: scrubbing ? 48 : 44,
height: widget.compact
? (scrubbing ? 32 : 20)
: (scrubbing ? 48 : 44),
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: scrubbing ? 4 : 3,
activeTrackColor: AppColors.gold,
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
trackHeight: widget.compact
? (scrubbing ? 3 : 1.5)
: (scrubbing ? 4 : 3),
activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.compact
? Colors.white.withValues(alpha: 0.18)
: Colors.white30,
thumbColor: widget.compact ? AppColors.blue : Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: scrubbing ? 8 : 6,
enabledThumbRadius: widget.compact
? (scrubbing ? 6 : 4)
: (scrubbing ? 8 : 6),
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compact ? 12 : 18,
),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
),
child: Semantics(
label: widget.semanticsLabel,
+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/drama.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/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
@@ -25,7 +26,7 @@ void main() {
});
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.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
@@ -44,27 +45,15 @@ void main() {
tester.getCenter(find.text('All')).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>(
find.byKey(const Key('home-all-dramas-grid')),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 3);
expect(delegate.crossAxisSpacing, 10);
expect(delegate.crossAxisCount, 2);
expect(delegate.crossAxisSpacing, 12);
expect(delegate.mainAxisSpacing, 14);
expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001));
expect(delegate.childAspectRatio, closeTo(0.55, 0.001));
expect(
tester
.widget<SliverPadding>(
@@ -80,7 +69,7 @@ void main() {
final cover = tester.widget<AspectRatio>(
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(
tester
.widget<Text>(
@@ -148,15 +137,7 @@ void main() {
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
expect(find.text('No matching dramas.'), findsOneWidget);
final content = tester.getCenter(
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));
expect(find.byKey(const Key('empty-state-content')), findsOneWidget);
await tester.tap(find.text('Romance'));
await tester.pumpAndSettle();
@@ -184,7 +165,7 @@ void main() {
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.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -204,11 +185,87 @@ void main() {
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
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(
WidgetTester tester, {
required CategoryRepository categoryRepository,
WatchHistoryRepository? history,
}) async {
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
@@ -216,7 +273,7 @@ Future<void> _pumpApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: categoryRepository,
watchHistoryRepository: InMemoryWatchHistoryRepository(),
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
+6 -21
View File
@@ -21,7 +21,7 @@ import '../support/widget_player.dart';
void main() {
testWidgets(
'should_render_home_feed_banners_when_discover_banners_are_empty',
'should_not_mount_carousel_on_discover_even_when_home_banners_exist',
(tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
@@ -43,26 +43,11 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byType(CoverCarousel), findsOneWidget);
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget);
expect(
find.descendant(
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,
);
expect(find.byType(CoverCarousel), findsNothing);
expect(find.byKey(const Key('discover-search')), findsOneWidget);
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
expect(find.text('The Signal'), findsWidgets);
await tester.pumpWidget(const SizedBox.shrink());
},
);
+39 -29
View File
@@ -18,17 +18,20 @@ void main() {
final dramas = [_drama('signal'), _drama('city')];
test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () {
final logic = HomeLogic(dramas);
test(
'should_make_first_catalog_entry_the_current_drama_when_catalog_loads',
() {
final logic = HomeLogic(dramas);
expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 1);
expect(logic.mySection, 0);
expect(logic.drawerOpen, isFalse);
expect(logic.profile, isFalse);
expect(logic.videoImmersive, isFalse);
logic.dispose();
});
expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 0);
expect(logic.mySection, 0);
expect(logic.drawerOpen, isFalse);
expect(logic.profile, isFalse);
expect(logic.videoImmersive, isFalse);
logic.dispose();
},
);
test('should_not_build_shell_when_catalog_is_empty', () {
expect(() => HomeLogic(const []), throwsStateError);
@@ -82,33 +85,38 @@ void main() {
logic.dispose();
});
test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () {
final logic = HomeLogic(dramas);
test(
'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used',
() {
final logic = HomeLogic(dramas);
logic.openProfile();
expect(logic.profile, isTrue);
expect(logic.tab, 2);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
logic.openProfile();
expect(logic.profile, isTrue);
expect(logic.tab, 2);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
logic.openLibrary(section: 1);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 2);
logic.openLibrary(section: 1);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 2);
logic.openLibrary();
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 3);
logic.dispose();
});
logic.openLibrary();
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 3);
logic.dispose();
},
);
test('should_return_home_and_open_drawer_when_system_back_is_pressed', () {
final logic = HomeLogic(dramas);
// Home is the launch tab; the first back press lands on Discover.
logic.onSystemBack();
expect(logic.tab, 1);
logic.onSystemBack();
expect(logic.drawerOpen, isTrue);
expect(logic.tab, 1);
expect(logic.backRequest, 0);
logic.onDrawerChanged(false);
@@ -190,7 +198,9 @@ void main() {
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);
late BuildContext context;
await tester.pumpWidget(
+36 -10
View File
@@ -18,9 +18,8 @@ import '../support/widget_player.dart';
const _keys = [
Key('home-tabs-bar'),
Key('tab-discover'),
Key('tab-home'),
Key('tab-my-list'),
Key('tab-discover'),
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,
) async {
final haptics = FakeHaptics();
@@ -52,10 +51,9 @@ void main() {
final bar = tester.widget<Container>(barFinder);
final decoration = bar.decoration as BoxDecoration;
final border = decoration.border as Border;
final context = tester.element(barFinder);
expect(decoration.color, AppColors.bar);
expect(border.top.color, Theme.of(context).colorScheme.outlineVariant);
expect(border.top.color.computeLuminance(), lessThan(0.2));
expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
expect(border.top.color, AppColors.border);
expect(border.top.color.a, lessThan(0.2));
expect(border.top.color, isNot(Colors.white));
expect(
tester
@@ -71,7 +69,7 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.movie_rounded),
matching: find.byIcon(Icons.home_rounded),
),
findsOneWidget,
);
@@ -85,14 +83,14 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-discover')),
matching: find.byIcon(Icons.home_rounded),
matching: find.byIcon(Icons.explore_rounded),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.movie_outlined),
matching: find.byIcon(Icons.home_outlined),
),
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', (
tester,
) async {
@@ -119,6 +143,8 @@ void main() {
await tester.pumpAndSettle();
haptics.reset();
await tester.drag(find.byType(ListView), const Offset(0, -700));
await tester.pumpAndSettle();
await tester.tap(find.text('App Language'));
await tester.pumpAndSettle();
+57 -19
View File
@@ -117,7 +117,12 @@ void main() {
expect(find.text('Following'), findsNothing);
expect(find.byTooltip('Episodes'), findsWidgets);
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.byTooltip('Explore series'), findsNothing);
});
@@ -155,6 +160,8 @@ void main() {
await tester.pumpWidget(appUnderTest());
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);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
@@ -217,15 +224,18 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Vicky sheng'), 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.pumpAndSettle();
await tester.tap(find.text('简体中文').last);
await tester.pumpAndSettle();
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);
await tester.tap(find.text('首页'));
await tester.tap(find.text('找剧'));
await tester.pumpAndSettle();
expect(find.text('全部'), findsOneWidget);
expect(find.text('热门'), findsOneWidget);
@@ -272,7 +282,7 @@ void main() {
expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.text('Shorts'));
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
},
@@ -311,25 +321,39 @@ void main() {
await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle();
await tester.tap(find.text('Home'));
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('All'), findsOneWidget);
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.text('Rewards'), findsOneWidget);
expect(find.text(r'$ 0'), findsOneWidget);
expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
expect(find.byKey(const Key('discover-profile')), findsOneWidget);
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,
);
expect(find.byTooltip('Search'), findsNothing);
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();
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();
expect(find.text('The Signal'), findsOneWidget);
},
@@ -345,7 +369,7 @@ void main() {
await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle();
await tester.tap(find.text('Home'));
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byTooltip('Search'), findsNothing);
@@ -356,14 +380,16 @@ void main() {
isFalse,
);
await tester.tap(find.byKey(const Key('tab-my-list')));
await tester.tap(find.byKey(const Key('tab-my')));
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(
Navigator.of(tester.element(find.text('History'))).canPop(),
Navigator.of(tester.element(find.text('History').first)).canPop(),
isFalse,
);
await tester.tap(find.text('Watchlist'));
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget);
@@ -377,7 +403,11 @@ void main() {
).canPop(),
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();
expect(find.text('No shows here yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-home')));
@@ -545,6 +575,8 @@ void main() {
await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('drawer-open-series')));
@@ -570,6 +602,8 @@ void main() {
await tester.pumpWidget(appUnderTest());
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);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
@@ -608,9 +642,13 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-save')));
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.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();
expect(find.text('The Signal'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);