feat(ui): 校正首页发现详情播放器预览
This commit is contained in:
1 parent
87db548591
commit
21d457fd98
13 files changed
+1218
-1684
No files matched your search
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -165,53 +164,41 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
l10n.trendingMicroDramas,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: AppDimens.previewSectionTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
categories.selectCode(CategoryController.hotCode),
|
||||
categories.selectCode(CategoryController.allCode),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.accent,
|
||||
minimumSize: const Size(44, 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
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),
|
||||
],
|
||||
child: Text(
|
||||
'${l10n.all} ›',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -236,55 +223,41 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
|
||||
child: Row(
|
||||
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: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.discoverTab,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_HeaderIcon(
|
||||
key: const Key('discover-search-button'),
|
||||
icon: Icons.search_rounded,
|
||||
tooltip: l10n.search,
|
||||
onTap: logic.openSearch,
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: widget.signedIn ? l10n.myTab : l10n.logIn,
|
||||
@@ -293,20 +266,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.raised,
|
||||
border: Border.all(
|
||||
color: AppColors.secondary.withValues(alpha: 0.7),
|
||||
width: 2,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6A5CFF), AppColors.blue],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppColors.onSurface,
|
||||
size: 21,
|
||||
size: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -318,9 +291,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _search() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: SizedBox(
|
||||
height: AppDimens.searchHeight,
|
||||
height: 36,
|
||||
child: TextField(
|
||||
key: const Key('discover-search'),
|
||||
controller: logic.search,
|
||||
@@ -331,27 +304,22 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: AppColors.card,
|
||||
fillColor: AppColors.input,
|
||||
hintText: AppLocalizations.of(context)!.searchDiscover,
|
||||
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5),
|
||||
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search_rounded,
|
||||
color: AppColors.subtle,
|
||||
size: 20,
|
||||
),
|
||||
suffixIcon: const Icon(
|
||||
Icons.mic_none_rounded,
|
||||
color: AppColors.muted,
|
||||
size: 19,
|
||||
size: 17,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||
borderSide: const BorderSide(color: AppColors.blue, width: 1.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderSide: const BorderSide(color: AppColors.blue, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -361,9 +329,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
||||
final choices = <({String code, String label})>[
|
||||
(code: CategoryController.allCode, label: l10n.all),
|
||||
(code: CategoryController.hotCode, label: l10n.hot),
|
||||
(code: CategoryController.newCode, label: l10n.newest),
|
||||
(code: CategoryController.allCode, label: l10n.recommended),
|
||||
for (final category in categories.categories)
|
||||
(code: 'category-${category.id}', label: category.name),
|
||||
];
|
||||
@@ -371,7 +337,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
key: const Key('home-category-chips'),
|
||||
height: AppDimens.tapTarget,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: choices.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
@@ -390,12 +356,10 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue : AppColors.card,
|
||||
color: selected ? AppColors.blue : const Color(0xFF29272B),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
color: selected ? AppColors.blue : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
@@ -403,7 +367,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||
fontSize: 12,
|
||||
fontSize: AppDimens.chipFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
@@ -423,23 +387,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
WatchProgress record,
|
||||
) {
|
||||
final progress = record.fraction;
|
||||
return GlassPanel(
|
||||
return Container(
|
||||
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)],
|
||||
padding: const EdgeInsets.all(9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 64,
|
||||
height: 88,
|
||||
width: 52,
|
||||
height: 66,
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
@@ -447,57 +408,38 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 9),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
l10n.continueWatching,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_resumeMeta(drama, record),
|
||||
_resumeShort(drama, record),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 10.5,
|
||||
),
|
||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
@@ -505,7 +447,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
child: LinearProgressIndicator(
|
||||
value: progress <= 0 ? null : progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.12),
|
||||
backgroundColor: const Color(0xFF474248),
|
||||
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
||||
),
|
||||
),
|
||||
@@ -525,29 +467,24 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
minimumSize: const Size(64, 34),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
shape: const StadiumBorder(),
|
||||
minimumSize: const Size(56, 28),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.play),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.play_arrow_rounded, size: 14),
|
||||
],
|
||||
),
|
||||
child: Text(l10n.resume),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resumeMeta(Drama drama, WatchProgress record) {
|
||||
String _resumeShort(Drama drama, WatchProgress record) {
|
||||
final label = AppLocalizations.of(context)!;
|
||||
Episode? episode;
|
||||
for (final item in drama.episodes) {
|
||||
@@ -556,10 +493,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
final head = episode == null
|
||||
? ''
|
||||
: '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · ';
|
||||
return '$head${_duration(record.position)} / ${_duration(record.duration)}';
|
||||
if (episode == null) return _duration(record.position);
|
||||
return label.episodeOfTotal(episode.number, drama.episodeCount);
|
||||
}
|
||||
|
||||
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
|
||||
@@ -574,7 +509,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
onTap: logic.openSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('discover-quick-charts'),
|
||||
@@ -584,17 +519,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
onTap: () => categories.selectCode(CategoryController.hotCode),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('discover-quick-new'),
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
label: l10n.newest,
|
||||
label: l10n.newReleases,
|
||||
color: AppColors.gold,
|
||||
onTap: () => categories.selectCode(CategoryController.newCode),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('discover-quick-watchlist'),
|
||||
@@ -617,8 +552,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('discover-nav-home'),
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home_rounded,
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
selectedIcon: Icons.play_circle_fill_rounded,
|
||||
label: l10n.homeTab,
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
@@ -637,8 +572,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('discover-nav-my'),
|
||||
icon: Icons.person_outline_rounded,
|
||||
selectedIcon: Icons.person_rounded,
|
||||
icon: Icons.video_library_outlined,
|
||||
selectedIcon: Icons.video_library_rounded,
|
||||
label: l10n.myTab,
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
@@ -651,6 +586,37 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
class _HeaderIcon extends StatelessWidget {
|
||||
const _HeaderIcon({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2C2D33),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
),
|
||||
icon: Icon(icon, size: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickAction extends StatelessWidget {
|
||||
const _QuickAction({
|
||||
super.key,
|
||||
@@ -668,31 +634,20 @@ class _QuickAction extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||
color: AppColors.quick,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
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),
|
||||
),
|
||||
height: 62,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
|
||||
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),
|
||||
Icon(icon, color: color, size: 16),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
@@ -700,7 +655,7 @@ class _QuickAction extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 9.5,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.05,
|
||||
),
|
||||
|
||||
@@ -37,27 +37,22 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (dramas.isEmpty) {
|
||||
final missingCategory = categories.selectedSectionMissing;
|
||||
return _fill(
|
||||
EmptyState(
|
||||
key: const Key('home-all-dramas-empty'),
|
||||
icon: searching
|
||||
? Icons.search_off_rounded
|
||||
: missingCategory
|
||||
? Icons.grid_view_rounded
|
||||
: Icons.movie_filter_outlined,
|
||||
title: searching
|
||||
? l10n.noSearchResults
|
||||
: missingCategory
|
||||
: categories.selectedSectionMissing
|
||||
? l10n.noCategoryDramas
|
||||
: l10n.noSearchResults,
|
||||
subtitle: l10n.emptyHint,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
_dramaGrid(dramas),
|
||||
@@ -68,14 +63,14 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
|
||||
Widget _dramaGrid(List<Drama> items) => SliverPadding(
|
||||
key: const Key('home-all-dramas-section'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
sliver: SliverGrid(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 14,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.55,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisExtent: 164,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _HomeDramaCard(
|
||||
@@ -88,9 +83,8 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _fill(Widget child) {
|
||||
return SliverFillRemaining(hasScrollBody: false, child: child);
|
||||
}
|
||||
Widget _fill(Widget child) =>
|
||||
SliverFillRemaining(hasScrollBody: false, child: child);
|
||||
}
|
||||
|
||||
class _HomeDramaCard extends StatelessWidget {
|
||||
@@ -107,18 +101,21 @@ class _HomeDramaCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||
final free =
|
||||
drama.freeEpisodes > 0 ||
|
||||
drama.episodes.any((episode) => episode.isFree);
|
||||
return InkWell(
|
||||
key: Key('home-drama-card-${drama.id}'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
SizedBox(
|
||||
key: Key('home-drama-cover-${drama.id}'),
|
||||
aspectRatio: 3 / 4.2,
|
||||
height: 120,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -132,120 +129,76 @@ class _HomeDramaCard extends StatelessWidget {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0x33000000),
|
||||
Color(0x00000000),
|
||||
Color(0xB3000000),
|
||||
],
|
||||
stops: [0, 0.42, 1],
|
||||
colors: [Color(0x0D000000), Color(0xCC000000)],
|
||||
stops: [0.45, 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)
|
||||
if (free)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.42),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
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,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
l10n.free,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 6,
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('home-drama-title-${drama.id}'),
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: 2),
|
||||
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,
|
||||
),
|
||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier {
|
||||
: null;
|
||||
}
|
||||
|
||||
bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null;
|
||||
|
||||
Future<void> playNextEpisode() async {
|
||||
final next = nextVisibleIndex(session.currentIndex);
|
||||
if (next == null || !pages.hasClients) return;
|
||||
await pages.animateToPage(
|
||||
visibleIndices.indexOf(next),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void beginScrub(VideoPlayerPort player) {
|
||||
if (session.active != player) return;
|
||||
scrubbingPlayer = player;
|
||||
|
||||
+308
-209
@@ -18,13 +18,13 @@ import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/platform/haptics.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_bottom_nav.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
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/player_featured_comment.dart';
|
||||
import 'widgets/seek_progress_bar.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
@@ -340,7 +340,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
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 {
|
||||
@@ -381,10 +380,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
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(
|
||||
@@ -398,9 +393,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onBack: widget.embedded
|
||||
? null
|
||||
: () => unawaited(logic.leave(context)),
|
||||
onDiscover: () => logic.showDiscover(context),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onMy: () => logic.showMy(context),
|
||||
onNext: logic.hasNextEpisode
|
||||
? () => unawaited(logic.playNextEpisode())
|
||||
: null,
|
||||
playing: logic.session.userWantsPlay,
|
||||
onTogglePlay: () =>
|
||||
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
@@ -434,7 +433,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onClear: () => logic.setScreenCleared(true),
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
? () => unawaited(logic.enterPip(context))
|
||||
: null,
|
||||
@@ -442,7 +440,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
showBottomNav: false,
|
||||
),
|
||||
if (logic.screenCleared)
|
||||
SafeArea(
|
||||
@@ -649,20 +646,19 @@ class _PlaybackOverlay extends StatelessWidget {
|
||||
required this.controller,
|
||||
required this.liked,
|
||||
required this.saved,
|
||||
required this.playing,
|
||||
required this.onTogglePlay,
|
||||
required this.onBack,
|
||||
required this.onDiscover,
|
||||
required this.onEpisodes,
|
||||
required this.onMy,
|
||||
required this.onComments,
|
||||
required this.onLike,
|
||||
required this.onSave,
|
||||
required this.onDownload,
|
||||
required this.onComments,
|
||||
required this.onShare,
|
||||
required this.onClear,
|
||||
required this.onPip,
|
||||
required this.onScrubStart,
|
||||
required this.onSeek,
|
||||
required this.showBottomNav,
|
||||
this.onNext,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
@@ -671,230 +667,333 @@ class _PlaybackOverlay extends StatelessWidget {
|
||||
final VideoPlayerController? controller;
|
||||
final bool liked;
|
||||
final bool saved;
|
||||
final bool playing;
|
||||
final VoidCallback onTogglePlay;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback onDiscover;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onMy;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback? onPip;
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showBottomNav;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
bottom: showBottomNav,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(context),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
key: const Key('player-play-toggle'),
|
||||
onTap: onTogglePlay,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
playing
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
right: 10,
|
||||
bottom: 14,
|
||||
child: Text(
|
||||
'1.0x',
|
||||
style: TextStyle(color: Color(0xFFDDDDDD), fontSize: 9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
SeekProgressBar(
|
||||
valueListenable: controller,
|
||||
semanticsLabel: l10n.seekVideo,
|
||||
showTimes: false,
|
||||
onScrubStart: onScrubStart,
|
||||
onSeek: onSeek,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-episodes'),
|
||||
icon: Icons.view_list_rounded,
|
||||
label: l10n.episodes,
|
||||
onTap: onEpisodes,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: FilledButton(
|
||||
key: const Key('player-next'),
|
||||
onPressed: onNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
disabledBackgroundColor: AppColors.raised,
|
||||
disabledForegroundColor: AppColors.subtle,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.nextEpisode,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.skip_next_rounded, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
PlayerFeaturedComment(drama: drama, onViewAll: onComments),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
child: Row(
|
||||
children: [
|
||||
if (onBack != null)
|
||||
_TopAction(
|
||||
key: const Key('player-back'),
|
||||
tooltip: l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 28),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${localizedDramaTitle(l10n, drama)} · '
|
||||
'${l10n.episodeLabel(episode.number)}',
|
||||
key: const Key('player-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_TopAction(
|
||||
key: const Key('player-more'),
|
||||
tooltip: l10n.more,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: [
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: _info()),
|
||||
_rail(),
|
||||
],
|
||||
ListTile(
|
||||
key: const Key('player-more-download'),
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.downloads),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onDownload();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
20 + MediaQuery.paddingOf(context).bottom,
|
||||
if (onPip != null)
|
||||
ListTile(
|
||||
key: const Key('player-more-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onPip!();
|
||||
},
|
||||
),
|
||||
child: SeekProgressBar(
|
||||
valueListenable: controller,
|
||||
semanticsLabel: l10n.seekVideo,
|
||||
onScrubStart: onScrubStart,
|
||||
onSeek: onSeek,
|
||||
ListTile(
|
||||
key: const Key('player-more-like'),
|
||||
leading: Icon(
|
||||
liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
color: liked ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
title: Text(l10n.like),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onLike();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-favorite'),
|
||||
leading: Icon(
|
||||
saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
color: saved ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
title: Text(l10n.favorite),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onSave();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-share'),
|
||||
leading: const Icon(Icons.share_outlined, color: AppColors.muted),
|
||||
title: Text(l10n.share),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onShare();
|
||||
},
|
||||
),
|
||||
if (showBottomNav) _fallbackNav(),
|
||||
],
|
||||
),
|
||||
if (onBack != null)
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 8,
|
||||
child: IconButton(
|
||||
tooltip: l10n.backToSeries,
|
||||
onPressed: onBack,
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: const Color(0x59000000),
|
||||
),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
tooltip: l10n.clearScreen,
|
||||
onPressed: onClear,
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: const Color(0x59000000),
|
||||
),
|
||||
icon: const Icon(Icons.fit_screen_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _info() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.15,
|
||||
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _rail() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RailAction(
|
||||
key: const Key('rail-like'),
|
||||
icon: liked ? Icons.favorite : Icons.favorite_border,
|
||||
label: l10n.like,
|
||||
selected: liked,
|
||||
onTap: onLike,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-save'),
|
||||
icon: saved ? Icons.bookmark : Icons.bookmark_border,
|
||||
label: l10n.save,
|
||||
selected: saved,
|
||||
onTap: onSave,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-download'),
|
||||
icon: Icons.download_outlined,
|
||||
label: l10n.downloads,
|
||||
onTap: onDownload,
|
||||
),
|
||||
_RailAction(
|
||||
icon: Icons.grid_view_outlined,
|
||||
label: l10n.episodes,
|
||||
onTap: onEpisodes,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-comments'),
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.share_outlined,
|
||||
label: l10n.share,
|
||||
onTap: onShare,
|
||||
),
|
||||
if (onPip != null)
|
||||
IconButton(
|
||||
tooltip: l10n.pictureInPicture,
|
||||
onPressed: onPip,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _railTapSize,
|
||||
height: _railTapSize,
|
||||
),
|
||||
color: AppColors.cream,
|
||||
icon: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
size: _railIconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _fallbackNav() => AppBottomNav(
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('player-nav-home'),
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
selectedIcon: Icons.play_circle_fill_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: true,
|
||||
onTap: () {},
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('player-nav-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
selectedIcon: Icons.explore_rounded,
|
||||
label: l10n.discoverTab,
|
||||
onTap: onDiscover,
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('player-nav-my'),
|
||||
icon: Icons.video_library_outlined,
|
||||
selectedIcon: Icons.video_library_rounded,
|
||||
label: l10n.myTab,
|
||||
onTap: onMy,
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const double _railIconSize = 28;
|
||||
const double _railIconWeight = 300;
|
||||
const double _railTapSize = 44;
|
||||
class _TopAction extends StatelessWidget {
|
||||
const _TopAction({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
class _RailAction extends StatelessWidget {
|
||||
const _RailAction({
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2C2D33),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
),
|
||||
icon: Icon(icon, size: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerAction extends StatelessWidget {
|
||||
const _PlayerAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
tooltip: label,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _railTapSize,
|
||||
height: _railTapSize,
|
||||
),
|
||||
color: selected ? AppColors.blueSoft : AppColors.cream,
|
||||
icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF2A2A30),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppColors.secondary),
|
||||
const SizedBox(width: 3),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,27 +3,21 @@
|
||||
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/theme/app_dimens.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.
|
||||
/// Home feed layer from `docs/移动端设计预览.html` screen 01.
|
||||
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,
|
||||
@@ -34,15 +28,12 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
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;
|
||||
@@ -54,12 +45,9 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
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(
|
||||
@@ -67,16 +55,16 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
_header(context),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 12, 4),
|
||||
child: _rail(compact),
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 10, 12),
|
||||
child: _rail(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||||
child: _infoCard(context, compact),
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
child: _infoCard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -85,79 +73,35 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 4),
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 10, 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_GlassIconButton(
|
||||
_RoundIcon(
|
||||
key: const Key('home-feed-search'),
|
||||
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(
|
||||
const SizedBox(width: 6),
|
||||
_RoundIcon(
|
||||
key: const Key('home-feed-more'),
|
||||
tooltip: l10n.more,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
icon: Icons.menu_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
],
|
||||
@@ -165,131 +109,53 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
Widget _rail() {
|
||||
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,
|
||||
_RailItem(
|
||||
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,
|
||||
_RailItem(
|
||||
key: const Key('rail-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
),
|
||||
_RailAction(
|
||||
compact: compact,
|
||||
_RailItem(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.share_outlined,
|
||||
icon: Icons.ios_share_rounded,
|
||||
label: l10n.share,
|
||||
onTap: onShare,
|
||||
),
|
||||
_RailItem(
|
||||
key: const Key('rail-save'),
|
||||
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
label: l10n.favorite,
|
||||
selected: saved,
|
||||
onTap: onSave,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard(BuildContext context, bool compact) {
|
||||
final genres = [
|
||||
if (drama.genre.isNotEmpty) drama.genre,
|
||||
if (drama.category.isNotEmpty) drama.category,
|
||||
].take(2).toList();
|
||||
Widget _infoCard() {
|
||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||
return GlassPanel(
|
||||
key: const Key('home-feed-info-card'),
|
||||
radius: 26,
|
||||
blur: 24,
|
||||
padding: const EdgeInsets.fromLTRB(16, 13, 16, 12),
|
||||
radius: 14,
|
||||
blur: 6,
|
||||
padding: const EdgeInsets.fromLTRB(12, 11, 12, 11),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0x260B0C10), Color(0x590B0C10)],
|
||||
colors: [Color(0xBF0F0F14), Color(0xA60F0F14)],
|
||||
),
|
||||
borderColor: Colors.white.withValues(alpha: 0.06),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -298,94 +164,73 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 5,
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (var i = 0; i < genres.length; i++)
|
||||
_genreChip(genres[i], i.isEven),
|
||||
if (genre.isNotEmpty)
|
||||
_tag(
|
||||
genre,
|
||||
background: AppColors.blue,
|
||||
foreground: AppColors.onPrimary,
|
||||
),
|
||||
if (drama.category.isNotEmpty && drama.category != genre)
|
||||
_tag(
|
||||
drama.category,
|
||||
background: const Color(0xFF333333),
|
||||
foreground: const Color(0xFFDDDDDD),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.episodeOfTotal(episode.number, drama.episodeCount),
|
||||
key: const Key('home-feed-episode'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
drama.synopsis,
|
||||
maxLines: compact ? 1 : 2,
|
||||
localizedEpisodeTitle(l10n, episode.number),
|
||||
key: const Key('home-feed-episode-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 12,
|
||||
height: 1.35,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SeekProgressBar(
|
||||
valueListenable: controller,
|
||||
semanticsLabel: l10n.seekVideo,
|
||||
showTimes: false,
|
||||
compact: true,
|
||||
onScrubStart: onScrubStart,
|
||||
onSeek: onSeek,
|
||||
),
|
||||
_timeRow(),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
height: 34,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('home-feed-follow-series'),
|
||||
onPressed: onSave,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: const StadiumBorder(),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.buttonRadius,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
saved ? Icons.check_rounded : Icons.add_rounded,
|
||||
size: 16,
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 16),
|
||||
label: Text(
|
||||
saved ? l10n.followingTab : l10n.followSeries,
|
||||
maxLines: 1,
|
||||
@@ -395,11 +240,33 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_GlassAction(
|
||||
tooltip: l10n.episodes,
|
||||
icon: Icons.grid_view_rounded,
|
||||
label: l10n.episodes,
|
||||
onPressed: onEpisodes,
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: FilledButton(
|
||||
key: const Key('home-feed-episodes'),
|
||||
onPressed: onEpisodes,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2A2A30),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.buttonRadius,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.episodes,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -408,41 +275,29 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _genreChip(String label, bool secondary) {
|
||||
final color = secondary ? AppColors.secondary : AppColors.blue;
|
||||
Widget _tag(
|
||||
String label, {
|
||||
required Color background,
|
||||
required Color foreground,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
color: foreground,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -456,7 +311,7 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
key: const Key('home-feed-download'),
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.downloads),
|
||||
onTap: () {
|
||||
@@ -469,7 +324,7 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
key: const Key('home-feed-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
@@ -478,9 +333,10 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('home-feed-clear'),
|
||||
leading: const Icon(
|
||||
Icons.fit_screen_outlined,
|
||||
color: AppColors.blueSoft,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.clearScreen),
|
||||
onTap: () {
|
||||
@@ -495,35 +351,9 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
class _RoundIcon extends StatelessWidget {
|
||||
const _RoundIcon({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
@@ -531,140 +361,76 @@ class _GlassIconButton extends StatelessWidget {
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.28),
|
||||
backgroundColor: const Color(0xCC2C2D33),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.16)),
|
||||
),
|
||||
icon: Icon(icon, size: 18),
|
||||
icon: Icon(icon, size: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
class _RailItem extends StatelessWidget {
|
||||
const _RailItem({
|
||||
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(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 19,
|
||||
size: 22,
|
||||
color: selected ? AppColors.blue : AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 9,
|
||||
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/repositories/comment_repository.dart';
|
||||
import '../../../core/data/repositories/interaction_repository.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
class PlayerFeaturedComment extends StatefulWidget {
|
||||
const PlayerFeaturedComment({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onViewAll,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback onViewAll;
|
||||
|
||||
@override
|
||||
State<PlayerFeaturedComment> createState() => _PlayerFeaturedCommentState();
|
||||
}
|
||||
|
||||
class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
|
||||
late final CommentController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = CommentController(
|
||||
comments: context.read<CommentRepository>(),
|
||||
interactions: context.read<InteractionRepository>(),
|
||||
dramaId: widget.drama.id,
|
||||
);
|
||||
unawaited(_controller.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
if (_controller.comments.isEmpty) return const SizedBox.shrink();
|
||||
return _card(context, _controller.comments.first);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context, DramaComment comment) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final time = _time(l10n, comment.createdAt);
|
||||
return InkWell(
|
||||
key: const Key('player-featured-comment'),
|
||||
onTap: widget.onViewAll,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.secondary.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Text(
|
||||
_initial(comment.nickname),
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
comment.nickname.isEmpty
|
||||
? l10n.localViewer
|
||||
: comment.nickname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (time != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'· $time',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF777777),
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
comment.content,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFDDDDDD),
|
||||
fontSize: 10,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _time(AppLocalizations l10n, DateTime? createdAt) {
|
||||
if (createdAt == null) return null;
|
||||
final diff = DateTime.now().difference(createdAt);
|
||||
if (diff.inMinutes < 1) return null;
|
||||
return l10n.minutesAgo(diff.inMinutes);
|
||||
}
|
||||
|
||||
String _initial(String nickname) {
|
||||
final trimmed = nickname.trim();
|
||||
return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.compact
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: Colors.white30,
|
||||
: const Color(0xFF474248),
|
||||
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: widget.compact
|
||||
|
||||
@@ -39,44 +39,42 @@ void main() {
|
||||
|
||||
expect(find.text('Continue Watching'), findsNothing);
|
||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
expect(find.text('Recommended'), findsOneWidget);
|
||||
expect(find.text('For You'), findsNothing);
|
||||
expect(
|
||||
tester.getCenter(find.text('All')).dx,
|
||||
lessThan(tester.getCenter(find.text('Hot')).dx),
|
||||
);
|
||||
final grid = tester.widget<SliverGrid>(
|
||||
find.byKey(const Key('home-all-dramas-grid')),
|
||||
);
|
||||
final delegate =
|
||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||
expect(delegate.crossAxisCount, 2);
|
||||
expect(delegate.crossAxisSpacing, 12);
|
||||
expect(delegate.mainAxisSpacing, 14);
|
||||
expect(delegate.childAspectRatio, closeTo(0.55, 0.001));
|
||||
expect(delegate.crossAxisSpacing, 10);
|
||||
expect(delegate.mainAxisSpacing, 10);
|
||||
expect(delegate.mainAxisExtent, 164);
|
||||
expect(
|
||||
tester
|
||||
.widget<SliverPadding>(
|
||||
find.byKey(const Key('home-all-dramas-section')),
|
||||
)
|
||||
.padding,
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
const EdgeInsets.symmetric(horizontal: 14),
|
||||
);
|
||||
expect(find.byType(GridView), findsNothing);
|
||||
expect(find.byType(CustomScrollView), findsOneWidget);
|
||||
expect(find.byType(RemoteImage), findsWidgets);
|
||||
|
||||
final cover = tester.widget<AspectRatio>(
|
||||
find.byKey(const Key('home-drama-cover-city-lights-demo')),
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const Key('home-drama-cover-city-lights-demo')))
|
||||
.height,
|
||||
120,
|
||||
);
|
||||
expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001));
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
find.byKey(const Key('home-drama-title-city-lights-demo')),
|
||||
)
|
||||
.maxLines,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
@@ -100,14 +98,14 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
expect(find.text('The Signal'), findsWidgets);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
|
||||
await tester.tap(find.text('Romance'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('The Signal'), findsNothing);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
expect(
|
||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||
findsNothing,
|
||||
@@ -182,7 +180,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import '../support/fake_haptics.dart';
|
||||
import '../support/fake_interaction_repositories.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', (
|
||||
testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
@@ -32,45 +32,24 @@ void main() {
|
||||
await tester.pumpWidget(_host(_detailDrama()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final hero = tester.widget<AspectRatio>(
|
||||
find.byKey(const Key('detail-hero')),
|
||||
);
|
||||
expect(hero.aspectRatio, closeTo(16 / 9, 0.001));
|
||||
expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190);
|
||||
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
|
||||
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
|
||||
expect(find.byKey(const Key('detail-download')), findsOneWidget);
|
||||
expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget);
|
||||
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40);
|
||||
expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('detail-episode-chip-1'))),
|
||||
const Size(46, 44),
|
||||
);
|
||||
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
|
||||
|
||||
final synopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('detail-synopsis')),
|
||||
);
|
||||
expect(synopsis.maxLines, 2);
|
||||
await tester.tap(find.byKey(const Key('detail-synopsis-toggle')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('detail-synopsis'))).maxLines,
|
||||
100,
|
||||
);
|
||||
expect(synopsis.style?.fontSize, 11);
|
||||
expect(synopsis.style?.height, 1.6);
|
||||
|
||||
final grid = tester.widget<SliverGrid>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('detail-episode-grid')),
|
||||
matching: find.byType(SliverGrid),
|
||||
),
|
||||
);
|
||||
final delegate =
|
||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||
expect(delegate.crossAxisCount, 2);
|
||||
expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))),
|
||||
const Size(96, 64),
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('detail-more')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
|
||||
expect(find.byKey(const Key('detail-download')), findsOneWidget);
|
||||
expect(find.byKey(const Key('detail-share')), findsOneWidget);
|
||||
expect(find.byKey(const Key('detail-report')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -90,7 +69,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget);
|
||||
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'package:fruitshorts/app.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/watch_history_repository.dart';
|
||||
|
||||
import '../support/fake_catalog.dart';
|
||||
import '../support/fake_category.dart';
|
||||
import '../support/fake_haptics.dart';
|
||||
import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/fake_notice_repository.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final catalog = FakeCatalogRepository();
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(
|
||||
playerFactory: createWidgetPlayer,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
noticeRepository: FakeNoticeRepository(),
|
||||
haptics: FakeHaptics(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-episode')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height,
|
||||
34,
|
||||
);
|
||||
expect(find.text('HOT'), findsNothing);
|
||||
expect(find.textContaining('Season'), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-follow')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -29,11 +29,15 @@ void main() {
|
||||
await _pumpApp(tester);
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('City Lights & Velvet Nights'));
|
||||
await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
_expectFullscreenOverlay();
|
||||
expect(find.byType(PageView), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('player-more')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('player-more-download')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-more-pip')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -57,24 +61,29 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('detail-episode-chip-2')));
|
||||
await tester.pumpAndSettle();
|
||||
_expectFullscreenOverlay();
|
||||
expect(find.text('Episode 2 · Playing now'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
|
||||
contains('Episode 2'),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
void _expectFullscreenOverlay() {
|
||||
expect(find.byKey(const Key('rail-like')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-save')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-comments')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-share')), findsOneWidget);
|
||||
expect(find.byTooltip('Picture in picture'), findsOneWidget);
|
||||
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-back')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-more')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-episodes')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-comments')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-next')), findsOneWidget);
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
|
||||
expect(find.byKey(const Key('rail-like')), findsNothing);
|
||||
expect(find.byKey(const Key('rail-save')), findsNothing);
|
||||
expect(find.byKey(const Key('card-player-video-frame')), findsNothing);
|
||||
expect(find.byKey(const Key('card-player-progress')), findsNothing);
|
||||
expect(find.byKey(const Key('card-player-next')), findsNothing);
|
||||
expect(find.byKey(const Key('player-title')), findsNothing);
|
||||
}
|
||||
|
||||
Future<void> _pumpApp(WidgetTester tester) async {
|
||||
|
||||
+35
-27
@@ -110,17 +110,19 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
expect(find.byType(Slider), findsNothing);
|
||||
expect(find.text('Home'), findsWidgets);
|
||||
expect(find.textContaining('Playing now'), findsOneWidget);
|
||||
expect(find.text('Recommended'), findsNothing);
|
||||
expect(find.text('Following'), findsNothing);
|
||||
expect(find.byTooltip('Episodes'), findsWidgets);
|
||||
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
||||
expect(find.byTooltip('Picture in picture'), findsNothing);
|
||||
await tester.tap(find.byTooltip('More'));
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-like')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-comments')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-share')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-save')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('home-feed-more')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Picture in picture'), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-download')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-pip')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-clear')), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Watch Full'), findsNothing);
|
||||
@@ -139,14 +141,16 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Clear screen'));
|
||||
await tester.tap(find.byKey(const Key('home-feed-more')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('home-feed-clear')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-tabs-bar')), findsNothing);
|
||||
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Exit clear screen'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
||||
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-more')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -233,12 +237,11 @@ void main() {
|
||||
expect(find.text('我的'), findsWidgets);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 700));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('我的账户'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-wallet')), 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);
|
||||
expect(find.text('推荐'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -259,7 +262,9 @@ void main() {
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('City Lights & Velvet Nights'));
|
||||
await tester.tap(
|
||||
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
@@ -324,7 +329,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
expect(find.text('Recommended'), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||
expect(find.byKey(const Key('discover-profile')), findsOneWidget);
|
||||
expect(
|
||||
@@ -341,7 +346,7 @@ void main() {
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byTooltip('Search'), findsNothing);
|
||||
expect(find.byTooltip('Search'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
@@ -372,7 +377,7 @@ void main() {
|
||||
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);
|
||||
expect(find.byTooltip('Search'), findsOneWidget);
|
||||
expect(
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
|
||||
@@ -396,7 +401,7 @@ void main() {
|
||||
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);
|
||||
expect(find.byTooltip('Search'), findsOneWidget);
|
||||
expect(
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
|
||||
@@ -440,12 +445,17 @@ void main() {
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('City Lights & Velvet Nights'));
|
||||
await tester.tap(
|
||||
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Episode 2 · Playing now'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
|
||||
contains('Episode 2'),
|
||||
);
|
||||
expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets);
|
||||
},
|
||||
);
|
||||
@@ -507,9 +517,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
final keys = [
|
||||
const Key('rail-like'),
|
||||
const Key('rail-save'),
|
||||
const Key('rail-comments'),
|
||||
const Key('rail-share'),
|
||||
const Key('rail-save'),
|
||||
];
|
||||
final tops = keys
|
||||
.map((key) => tester.getTopLeft(find.byKey(key)).dy)
|
||||
@@ -644,10 +654,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.byKey(const Key('mine-library-tab-1')),
|
||||
300,
|
||||
);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -320));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
|
||||
Reference in new issue
Block a user