fix(flutter): 首页 feed 顶栏固定悬浮
This commit is contained in:
1 parent
1cb4c56261
commit
9175a7f253
6 files changed
+341
-345
No files matched your search
@@ -23,6 +23,7 @@ import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
import 'widgets/home_feed_header.dart';
|
||||
import 'widgets/home_feed_overlay.dart';
|
||||
import 'widgets/exit_clear_screen_button.dart';
|
||||
import 'widgets/player_center_toggle.dart';
|
||||
@@ -187,6 +188,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
|
||||
Widget _buildPlayer(BuildContext context) {
|
||||
final interactions = context.watch<InteractionController>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final indices = logic.visibleIndices;
|
||||
final pages = logic.pages;
|
||||
final content = AnimatedBuilder(
|
||||
@@ -205,21 +207,55 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
_buildEpisode(context, indices[position], interactions),
|
||||
),
|
||||
);
|
||||
final body = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
content,
|
||||
if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: HomeFeedHeader(
|
||||
l10n: l10n,
|
||||
onRefresh: () async {
|
||||
final ok = await widget.onRefreshFeed?.call() ?? true;
|
||||
if (context.mounted && !ok) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.catalogError)));
|
||||
}
|
||||
return ok;
|
||||
},
|
||||
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
|
||||
onMore: _openCurrentMore,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.dark,
|
||||
child: Theme(
|
||||
data: AppTheme.light(),
|
||||
child: widget.embedded
|
||||
? content
|
||||
? body
|
||||
: Scaffold(
|
||||
key: const Key('player-scaffold'),
|
||||
backgroundColor: Colors.transparent,
|
||||
body: content,
|
||||
body: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openCurrentMore() {
|
||||
final entry = logic.queue.entryAt(logic.session.currentIndex);
|
||||
_openMore(context, entry.episode);
|
||||
}
|
||||
|
||||
Widget _buildEpisode(
|
||||
BuildContext context,
|
||||
int index,
|
||||
@@ -417,26 +453,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
},
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onDetail: () => unawaited(logic.openDetail(context)),
|
||||
onOpenFullPlayer: () async {
|
||||
await widget.onOpenFullPlayer?.call(drama, episode);
|
||||
if (!mounted || !widget.active) return;
|
||||
logic.session.setUserWantsPlay(true);
|
||||
},
|
||||
onRefreshFeed: () async {
|
||||
final ok = await widget.onRefreshFeed?.call() ?? true;
|
||||
if (context.mounted && !ok) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.catalogError)));
|
||||
}
|
||||
return ok;
|
||||
},
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onClear: () => setState(() => _chromeVisible = false),
|
||||
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
|
||||
onMore: () => _openMore(context, episode),
|
||||
onMore: _openCurrentMore,
|
||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _topIconSize = 36;
|
||||
const double _topIconGlyph = 22;
|
||||
const double _tabGap = 22;
|
||||
const double _tabFontSize = 15;
|
||||
const double _underlineWidth = 18;
|
||||
const double _underlineHeight = 2;
|
||||
|
||||
/// 首页 feed 固定顶栏:刷新 / 漫剧·真人剧·推荐 / 搜索。
|
||||
class HomeFeedHeader extends StatefulWidget {
|
||||
const HomeFeedHeader({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.onRefresh,
|
||||
required this.onSearch,
|
||||
required this.onMore,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Future<bool> Function() onRefresh;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onMore;
|
||||
|
||||
@override
|
||||
State<HomeFeedHeader> createState() => _HomeFeedHeaderState();
|
||||
}
|
||||
|
||||
class _HomeFeedHeaderState extends State<HomeFeedHeader> {
|
||||
_FeedTab _tab = _FeedTab.recommend;
|
||||
bool _refreshing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_RefreshButton(
|
||||
tooltip: widget.l10n.retry,
|
||||
dimension: _topIconSize,
|
||||
glyphSize: _topIconGlyph,
|
||||
refreshing: _refreshing,
|
||||
onTap: _refresh,
|
||||
onLongPress: widget.onMore,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < _FeedTab.values.length; index++)
|
||||
_tabSlot(_FeedTab.values[index], index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_TopIconButton(
|
||||
key: const Key('home-feed-search'),
|
||||
tooltip: widget.l10n.search,
|
||||
icon: Icons.search_rounded,
|
||||
dimension: _topIconSize,
|
||||
glyphSize: _topIconGlyph,
|
||||
onPressed: widget.onSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabSlot(_FeedTab tab, int index) {
|
||||
final item = _FeedTabItem(
|
||||
tab: tab,
|
||||
label: tab.label(widget.l10n),
|
||||
selected: _tab == tab,
|
||||
fontSize: _tabFontSize,
|
||||
underlineWidth: _underlineWidth,
|
||||
underlineHeight: _underlineHeight,
|
||||
onTap: () => _selectTab(tab),
|
||||
);
|
||||
if (index == 0) return item;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: _tabGap),
|
||||
child: item,
|
||||
);
|
||||
}
|
||||
|
||||
void _selectTab(_FeedTab tab) {
|
||||
if (tab.comingSoon) {
|
||||
_showComingSoon();
|
||||
return;
|
||||
}
|
||||
setState(() => _tab = tab);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
setState(() => _refreshing = true);
|
||||
await widget.onRefresh();
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
|
||||
void _showComingSoon() {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部频道 tab:漫剧点击提示即将上线。
|
||||
enum _FeedTab {
|
||||
anime,
|
||||
real,
|
||||
recommend;
|
||||
|
||||
bool get comingSoon => this == _FeedTab.anime;
|
||||
|
||||
String get keyName => switch (this) {
|
||||
_FeedTab.anime => 'anime',
|
||||
_FeedTab.real => 'real',
|
||||
_FeedTab.recommend => 'recommend',
|
||||
};
|
||||
|
||||
String label(AppLocalizations l10n) => switch (this) {
|
||||
_FeedTab.anime => l10n.feedTabAnime,
|
||||
_FeedTab.real => l10n.feedTabReal,
|
||||
_FeedTab.recommend => l10n.feedTabForYou,
|
||||
};
|
||||
}
|
||||
|
||||
class _TopIconButton extends StatelessWidget {
|
||||
const _TopIconButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.dimension,
|
||||
required this.glyphSize,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final double dimension;
|
||||
final double glyphSize;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: glyphSize,
|
||||
color: Colors.white,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新入口:点击重新加载 feed,长按打开更多(清屏等)。
|
||||
class _RefreshButton extends StatelessWidget {
|
||||
const _RefreshButton({
|
||||
required this.tooltip,
|
||||
required this.dimension,
|
||||
required this.glyphSize,
|
||||
required this.refreshing,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final double dimension;
|
||||
final double glyphSize;
|
||||
final bool refreshing;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: GestureDetector(
|
||||
key: const Key('home-feed-refresh'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: refreshing
|
||||
? const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('home-feed-refreshing'),
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.refresh_rounded,
|
||||
size: glyphSize,
|
||||
color: Colors.white,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedTabItem extends StatelessWidget {
|
||||
const _FeedTabItem({
|
||||
required this.tab,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.fontSize,
|
||||
required this.underlineWidth,
|
||||
required this.underlineHeight,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _FeedTab tab;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final double fontSize;
|
||||
final double underlineWidth;
|
||||
final double underlineHeight;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: Key('home-feed-tab-${tab.keyName}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : Colors.white70,
|
||||
fontSize: fontSize,
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: underlineWidth,
|
||||
height: underlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,6 @@ part 'home_feed_overlay_parts.dart';
|
||||
|
||||
const Color _overlayPill = Color(0x66000000);
|
||||
const Color _fullBarBackground = Color(0x4D000000);
|
||||
const double _topIconSize = 36;
|
||||
const double _topIconGlyph = 22;
|
||||
const double _tabGap = 22;
|
||||
const double _tabFontSize = 15;
|
||||
const double _underlineWidth = 18;
|
||||
const double _underlineHeight = 2;
|
||||
const double _titleFontSize = 17;
|
||||
const double _metaFontSize = 12;
|
||||
const double _tagFontSize = 11;
|
||||
@@ -33,7 +27,7 @@ const List<Shadow> _boostTextShadows = [
|
||||
const int _maxTags = 4;
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。
|
||||
/// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。
|
||||
class HomeFeedOverlay extends StatefulWidget {
|
||||
const HomeFeedOverlay({
|
||||
super.key,
|
||||
@@ -49,23 +43,18 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
required this.onSave,
|
||||
required this.onComments,
|
||||
required this.onShare,
|
||||
required this.onEpisodes,
|
||||
required this.onDetail,
|
||||
required this.onOpenFullPlayer,
|
||||
required this.onRefreshFeed,
|
||||
required this.onDownload,
|
||||
required this.onClear,
|
||||
required this.onSearch,
|
||||
required this.onMore,
|
||||
required this.onTogglePlay,
|
||||
required this.onBoostStart,
|
||||
required this.onBoostEnd,
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
required this.onMore,
|
||||
this.likeCount,
|
||||
this.favoriteCount,
|
||||
this.commentCount,
|
||||
this.authorNote = '',
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
@@ -84,28 +73,20 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onDetail;
|
||||
final VoidCallback onOpenFullPlayer;
|
||||
final Future<bool> Function() onRefreshFeed;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onMore;
|
||||
final VoidCallback onTogglePlay;
|
||||
final VoidCallback? onScrubStart;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
final VoidCallback onBoostStart;
|
||||
final VoidCallback onBoostEnd;
|
||||
final VoidCallback onMore;
|
||||
final VoidCallback? onScrubStart;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
|
||||
@override
|
||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||
}
|
||||
|
||||
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
_FeedTab _tab = _FeedTab.recommend;
|
||||
bool _refreshing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
@@ -134,7 +115,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
_header(context),
|
||||
const Spacer(),
|
||||
if (widget.visible) _bottomInfo(),
|
||||
_fullSeriesBar(),
|
||||
@@ -185,77 +165,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
setState(() => _refreshing = true);
|
||||
await widget.onRefreshFeed();
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_RefreshButton(
|
||||
tooltip: widget.l10n.retry,
|
||||
dimension: _topIconSize,
|
||||
glyphSize: _topIconGlyph,
|
||||
refreshing: _refreshing,
|
||||
onTap: _refresh,
|
||||
onLongPress: widget.onMore,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < _FeedTab.values.length; index++)
|
||||
_tabSlot(_FeedTab.values[index], index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_TopIconButton(
|
||||
key: const Key('home-feed-search'),
|
||||
tooltip: widget.l10n.search,
|
||||
icon: Icons.search_rounded,
|
||||
dimension: _topIconSize,
|
||||
glyphSize: _topIconGlyph,
|
||||
onPressed: widget.onSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabSlot(_FeedTab tab, int index) {
|
||||
final item = _FeedTabItem(
|
||||
tab: tab,
|
||||
label: tab.label(widget.l10n),
|
||||
selected: _tab == tab,
|
||||
fontSize: _tabFontSize,
|
||||
underlineWidth: _underlineWidth,
|
||||
underlineHeight: _underlineHeight,
|
||||
onTap: () => _selectTab(tab),
|
||||
);
|
||||
if (index == 0) return item;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: _tabGap),
|
||||
child: item,
|
||||
);
|
||||
}
|
||||
|
||||
void _selectTab(_FeedTab tab) {
|
||||
if (tab.comingSoon) {
|
||||
_showComingSoon();
|
||||
return;
|
||||
}
|
||||
setState(() => _tab = tab);
|
||||
}
|
||||
|
||||
Widget _rail() {
|
||||
return InteractionRail(
|
||||
items: [
|
||||
@@ -377,11 +286,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -494,10 +399,4 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showComingSoon() {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
|
||||
}
|
||||
}
|
||||
@@ -7,167 +7,6 @@ const double _thumbActiveSize = 14;
|
||||
const double _trackActiveHeight = 6;
|
||||
const double _lineCenterFromBottom = 8;
|
||||
|
||||
/// 顶部频道 tab:漫剧点击提示即将上线。
|
||||
enum _FeedTab {
|
||||
anime,
|
||||
real,
|
||||
recommend;
|
||||
|
||||
bool get comingSoon => this == _FeedTab.anime;
|
||||
|
||||
String get keyName => switch (this) {
|
||||
_FeedTab.anime => 'anime',
|
||||
_FeedTab.real => 'real',
|
||||
_FeedTab.recommend => 'recommend',
|
||||
};
|
||||
|
||||
String label(AppLocalizations l10n) => switch (this) {
|
||||
_FeedTab.anime => l10n.feedTabAnime,
|
||||
_FeedTab.real => l10n.feedTabReal,
|
||||
_FeedTab.recommend => l10n.feedTabForYou,
|
||||
};
|
||||
}
|
||||
|
||||
class _TopIconButton extends StatelessWidget {
|
||||
const _TopIconButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.dimension,
|
||||
required this.glyphSize,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final double dimension;
|
||||
final double glyphSize;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: glyphSize,
|
||||
color: Colors.white,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。
|
||||
class _RefreshButton extends StatelessWidget {
|
||||
const _RefreshButton({
|
||||
required this.tooltip,
|
||||
required this.dimension,
|
||||
required this.glyphSize,
|
||||
required this.refreshing,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final double dimension;
|
||||
final double glyphSize;
|
||||
final bool refreshing;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: GestureDetector(
|
||||
key: const Key('home-feed-refresh'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: refreshing
|
||||
? const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('home-feed-refreshing'),
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.refresh_rounded,
|
||||
size: glyphSize,
|
||||
color: Colors.white,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedTabItem extends StatelessWidget {
|
||||
const _FeedTabItem({
|
||||
required this.tab,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.fontSize,
|
||||
required this.underlineWidth,
|
||||
required this.underlineHeight,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _FeedTab tab;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final double fontSize;
|
||||
final double underlineWidth;
|
||||
final double underlineHeight;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: Key('home-feed-tab-${tab.keyName}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : Colors.white70,
|
||||
fontSize: fontSize,
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: underlineWidth,
|
||||
height: underlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
|
||||
class _FeedProgressBar extends StatefulWidget {
|
||||
const _FeedProgressBar({
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
@@ -118,13 +119,8 @@ void main() {
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onEpisodes: () {},
|
||||
onDetail: () {},
|
||||
onOpenFullPlayer: () {},
|
||||
onRefreshFeed: () async => true,
|
||||
onDownload: () {},
|
||||
onClear: () {},
|
||||
onSearch: () {},
|
||||
onMore: () {},
|
||||
),
|
||||
),
|
||||
@@ -156,10 +152,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_refresh_seek_and_open_player_from_feed_actions', (
|
||||
tester,
|
||||
) async {
|
||||
var refreshed = 0;
|
||||
testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async {
|
||||
var opened = 0;
|
||||
Duration? seeked;
|
||||
final controller = ValueNotifier<VideoPlayerValue>(
|
||||
@@ -206,16 +199,8 @@ void main() {
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onEpisodes: () {},
|
||||
onDetail: () {},
|
||||
onOpenFullPlayer: () => opened++,
|
||||
onRefreshFeed: () async {
|
||||
refreshed++;
|
||||
return true;
|
||||
},
|
||||
onDownload: () {},
|
||||
onClear: () {},
|
||||
onSearch: () {},
|
||||
onMore: () {},
|
||||
onScrubStart: () {},
|
||||
onSeek: (value) => seeked = value,
|
||||
@@ -260,10 +245,6 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-refresh')));
|
||||
await tester.pump();
|
||||
expect(refreshed, 1);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||
);
|
||||
@@ -321,42 +302,12 @@ void main() {
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: HomeFeedOverlay(
|
||||
body: HomeFeedHeader(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
drama: const Drama(
|
||||
id: 'refresh-drama',
|
||||
title: 'Refresh Drama',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 8,
|
||||
),
|
||||
episode: const Episode(
|
||||
id: 'refresh-episode',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
isFree: true,
|
||||
),
|
||||
controller: null,
|
||||
liked: false,
|
||||
saved: false,
|
||||
visible: true,
|
||||
boosting: false,
|
||||
onTogglePlay: () {},
|
||||
onBoostStart: () {},
|
||||
onBoostEnd: () {},
|
||||
onLike: () {},
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onEpisodes: () {},
|
||||
onDetail: () {},
|
||||
onOpenFullPlayer: () {},
|
||||
onRefreshFeed: () {
|
||||
onRefresh: () {
|
||||
calls++;
|
||||
return completer.future;
|
||||
},
|
||||
onDownload: () {},
|
||||
onClear: () {},
|
||||
onSearch: () {},
|
||||
onMore: () {},
|
||||
),
|
||||
@@ -427,13 +378,8 @@ void main() {
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onEpisodes: () {},
|
||||
onDetail: () {},
|
||||
onOpenFullPlayer: () {},
|
||||
onRefreshFeed: () async => true,
|
||||
onDownload: () {},
|
||||
onClear: () {},
|
||||
onSearch: () {},
|
||||
onMore: () {},
|
||||
onScrubStart: () {},
|
||||
onSeek: (value) => seeked = value,
|
||||
@@ -444,7 +390,6 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress')), findsOneWidget);
|
||||
|
||||
|
||||
@@ -170,12 +170,14 @@ void main() {
|
||||
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
||||
expect(find.byKey(const Key('rail-save')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-search')), findsNothing);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-save')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-search')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_resume_playback_when_boosting_while_paused', (
|
||||
@@ -243,6 +245,19 @@ void main() {
|
||||
expect(after, isNot(before));
|
||||
});
|
||||
|
||||
testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
final before = tester.getRect(find.byKey(const Key('home-feed-search')));
|
||||
|
||||
await tester.drag(find.byType(PageView).first, const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final after = tester.getRect(find.byKey(const Key('home-feed-search')));
|
||||
expect(after.top, closeTo(before.top, 0.5));
|
||||
expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_resume_feed_after_returning_from_full_player', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in new issue
Block a user