feat(recommend): 对齐沉浸式推荐页视觉布局

This commit is contained in:
Zhou Weidong committed 2026-10-22 19:42:22 +08:00
1 parent 77689a3eaa
commit 5dd3492aea
17 files changed
+831 -398

No files matched your search

+55 -256
View File
@@ -9,30 +9,23 @@ import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/playback_thumbnail_service.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'interaction_rail.dart';
import 'home_feed_recommend_panel.dart';
import 'loading_progress_track.dart';
import 'scrub_preview_card.dart';
import 'seek_progress_bar.dart';
part 'home_feed_overlay_parts.dart';
const Color _overlayPill = AppColors.overlaySoft;
const Color _fullBarBackground = AppColors.overlayBar;
const double _titleFontSize = 17;
const double _metaFontSize = 12;
const double _tagFontSize = 11;
const double _fullBarHeight = 44;
const double _progressHeight = 2;
const double _progressHitHeight = 24;
const double _progressHitHeight = 12;
const double _pageHorizontal = 14;
const double _railGap = 12;
const double _railBottomOffset = 60;
const List<Shadow> _boostTextShadows = [
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
];
const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。
class HomeFeedOverlay extends StatefulWidget {
@@ -53,6 +46,7 @@ class HomeFeedOverlay extends StatefulWidget {
required this.onShare,
required this.onDetail,
required this.onOpenFullPlayer,
this.onEpisodes,
required this.onTogglePlay,
required this.onBoostStart,
required this.onBoostEnd,
@@ -86,6 +80,7 @@ class HomeFeedOverlay extends StatefulWidget {
final VoidCallback onShare;
final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer;
final VoidCallback? onEpisodes;
final VoidCallback onTogglePlay;
final VoidCallback onBoostStart;
final VoidCallback onBoostEnd;
@@ -124,17 +119,42 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
),
if (widget.boosting)
Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()])
else
Column(
children: [
const Spacer(),
if (widget.visible) _bottomInfo(),
_fullSeriesBar(),
],
Positioned(left: 0, right: 0, bottom: 20, child: _boostHint())
else if (widget.visible)
Positioned(
left: _pageHorizontal,
right: _pageHorizontal,
bottom: 12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _bottomInfo()),
const SizedBox(width: _railGap),
Padding(
padding: const EdgeInsets.only(bottom: _railBottomOffset),
child: _rail(),
),
],
),
),
if (widget.visible && !widget.boosting)
Positioned(right: 10, bottom: 56, child: _rail()),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _FeedProgressBar(
controller: widget.controller,
locked: widget.locked,
height: _progressHeight,
hitHeight: _progressHitHeight,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
),
),
],
),
);
@@ -179,244 +199,23 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
}
Widget _rail() {
return InteractionRail(
items: [
InteractionRailItem(
key: const Key('rail-save'),
asset: 'assets/icon/rail/rail_save.png',
label: widget.l10n.favorite,
count: widget.favoriteCount,
countKey: const Key('rail-save-count'),
selected: widget.saved,
onTap: widget.onSave,
),
InteractionRailItem(
key: const Key('rail-comments'),
asset: 'assets/icon/rail/rail_comment.png',
label: widget.l10n.comments,
count: widget.commentCount,
countKey: const Key('rail-comments-count'),
onTap: widget.onComments,
),
InteractionRailItem(
key: const Key('rail-like'),
asset: 'assets/icon/rail/rail_like.png',
label: widget.l10n.like,
count: widget.likeCount,
countKey: const Key('rail-like-count'),
selected: widget.liked,
onTap: widget.onLike,
),
InteractionRailItem(
key: const Key('rail-share'),
asset: 'assets/icon/rail/rail_share.png',
label: widget.l10n.share,
onTap: widget.onShare,
),
],
return HomeFeedActionRail(
saved: widget.saved,
liked: widget.liked,
favoriteCount: widget.favoriteCount,
likeCount: widget.likeCount,
onSave: widget.onSave,
onLike: widget.onLike,
onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer,
);
}
Widget _bottomInfo() {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 56, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_seriesMetaRow(),
const SizedBox(height: 2),
_titleRow(),
const SizedBox(height: 6),
_tagsRow(),
if (widget.authorNote.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'${widget.l10n.authorNoteLabel}: ${widget.authorNote}',
key: const Key('home-feed-author-note'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.muted, fontSize: 11),
),
],
],
),
);
}
Widget _seriesMetaRow() {
return GestureDetector(
key: const Key('home-feed-series-meta'),
onTap: widget.onDetail,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.l10n.seriesMeta(
'${widget.drama.episodeCount}',
formatInteractionCount(widget.drama.viewCount),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: _metaFontSize,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.muted,
size: 16,
),
],
),
);
}
Widget _titleRow() {
return GestureDetector(
onTap: widget.onDetail,
child: Row(
children: [
Flexible(
child: Text(
localizedDramaTitle(widget.l10n, widget.drama),
key: const Key('home-feed-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onPrimary,
fontSize: _titleFontSize,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 8, color: AppColors.mediaBlack)],
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.onPrimary,
size: 20,
),
],
),
);
}
Widget _tagsRow() {
final tags = [
widget.l10n.episodeLabel(widget.episode.number),
..._tagValues(),
].take(_maxTags).toList();
return Wrap(
key: const Key('home-feed-tags'),
spacing: 6,
runSpacing: 4,
children: [
for (final tag in tags)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _overlayPill,
borderRadius: BorderRadius.circular(6),
),
child: Text(
tag,
maxLines: 1,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: _tagFontSize,
),
),
),
],
);
}
List<String> _tagValues() {
final result = <String>[];
for (final value in [widget.drama.genre, widget.drama.category]) {
for (final part in value.split(_tagSplitter)) {
final tag = part.trim();
if (tag.isEmpty || result.contains(tag)) continue;
result.add(tag);
}
}
return result;
}
Widget _fullSeriesBar() {
return GestureDetector(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
child: SizedBox(
height: _fullBarHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Container(
key: const Key('home-feed-full-series-bg'),
color: _fullBarBackground,
),
),
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.l10n.watchFullSeries(
'${widget.drama.episodeCount}',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.onPrimary,
size: 18,
),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _FeedProgressBar(
controller: widget.controller,
locked: widget.locked,
height: _progressHeight,
hitHeight: _progressHitHeight,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
),
),
],
),
),
return HomeFeedInfoPanel(
drama: widget.drama,
episode: widget.episode,
onDetail: widget.onDetail,
onOpenFullPlayer: widget.onOpenFullPlayer,
);
}
}
@@ -0,0 +1,476 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart';
const double _headlineFont = 26;
const double _seriesFont = 17;
const double _metaFont = 12;
const double _tagFont = 12;
const double _synopsisFont = 13;
const double _watchAllHeight = 44;
const double _actionGap = 28;
const double _actionIcon = 32;
const double _actionWidth = 64;
const List<Shadow> _textShadows = [
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
];
final RegExp _tagSplitter = RegExp(r'[,/、·]');
class HomeFeedInfoPanel extends StatefulWidget {
const HomeFeedInfoPanel({
super.key,
required this.drama,
required this.episode,
required this.onDetail,
required this.onOpenFullPlayer,
});
final Drama drama;
final Episode episode;
final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer;
@override
State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState();
}
class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
bool _synopsisExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
final episodeTitle = widget.episode.title.trim();
final headline = episodeTitle.isEmpty ? dramaTitle : episodeTitle;
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
final genres = _genreValues();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
headline,
key: const Key('home-feed-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _headlineFont,
fontWeight: FontWeight.w700,
height: 1.2,
letterSpacing: 0,
shadows: _textShadows,
),
),
const SizedBox(height: 8),
_metaPills(l10n),
if (_hasMeta()) const SizedBox(height: 8),
GestureDetector(
key: const Key('home-feed-series-name'),
behavior: HitTestBehavior.opaque,
onTap: widget.onDetail,
child: Row(
children: [
Expanded(
child: Text(
dramaTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _seriesFont,
fontWeight: FontWeight.w600,
shadows: _textShadows,
),
),
),
const Icon(
Icons.chevron_right_rounded,
size: 16,
color: AppColors.overlaySecondary,
),
],
),
),
const SizedBox(height: 8),
_tagMetaRow(l10n, genres),
if (synopsis.isNotEmpty) ...[
const SizedBox(height: 8),
_synopsis(l10n, synopsis),
],
const SizedBox(height: 12),
_watchAll(l10n),
],
);
}
bool _hasMeta() {
return isHotDrama(widget.drama) || widget.drama.viewCount > 0;
}
Widget _metaPills(AppLocalizations l10n) {
final hot = isHotDrama(widget.drama);
final views = widget.drama.viewCount;
if (!hot && views <= 0) return const SizedBox.shrink();
return Wrap(
key: const Key('home-feed-meta-pills'),
spacing: 6,
runSpacing: 4,
children: [
if (hot)
_pill(
key: const Key('home-feed-ranking-pill'),
background: AppColors.error,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.local_fire_department_rounded,
size: 12,
color: AppColors.textPrimary,
),
const SizedBox(width: 2),
Text(
l10n.rankingHot,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _metaFont,
fontWeight: FontWeight.w600,
),
),
],
),
),
if (views > 0)
_pill(
key: const Key('home-feed-view-pill'),
background: AppColors.overlaySoft,
child: Text(
formatInteractionCount(views),
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _metaFont,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _pill({
required Key key,
required Color background,
required Widget child,
}) {
return Container(
key: key,
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 8),
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(11),
),
child: child,
);
}
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
return Row(
key: const Key('home-feed-tags'),
children: [
if (isHotDrama(widget.drama)) ...[
Container(
key: const Key('home-feed-hot-badge'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(3),
),
child: Text(
l10n.homeBadgeHot,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
],
Expanded(
child: Text(
genres.join(' | '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.overlaySecondary,
fontSize: _tagFont,
),
),
),
const SizedBox(width: 6),
Text(
l10n.favoriteEpisodeProgress(
widget.episode.number,
widget.drama.episodeCount,
),
key: const Key('home-feed-episode-progress'),
style: const TextStyle(
color: AppColors.overlaySecondary,
fontSize: _metaFont,
),
),
],
);
}
Widget _synopsis(AppLocalizations l10n, String synopsis) {
const style = TextStyle(
color: AppColors.overlaySecondary,
fontSize: _synopsisFont,
height: 1.35,
shadows: _textShadows,
);
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: synopsis, style: style),
maxLines: 2,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: constraints.maxWidth);
final overflows = painter.didExceedMaxLines;
if (!overflows) {
return Text(
synopsis,
key: const Key('home-feed-synopsis'),
style: style,
);
}
if (_synopsisExpanded) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
synopsis,
key: const Key('home-feed-synopsis'),
style: style,
),
const SizedBox(height: 2),
_synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.showLess,
),
],
);
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 40),
child: Text(
synopsis,
key: const Key('home-feed-synopsis'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: style,
),
),
Positioned(
right: 0,
bottom: 0,
child: _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.more,
),
),
],
);
},
);
}
Widget _synopsisToggle({required Key key, required String label}) {
return GestureDetector(
key: key,
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
child: Text(
label,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _watchAll(AppLocalizations l10n) {
return SizedBox(
width: double.infinity,
height: _watchAllHeight,
child: DecoratedBox(
key: const Key('home-feed-full-series-bg'),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.actionRed, AppColors.blue],
),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: AppColors.transparent,
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
borderRadius: BorderRadius.circular(8),
child: Center(
child: Text(
l10n.homeWatchAll,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
List<String> _genreValues() {
final result = <String>[];
for (final value in [widget.drama.genre, widget.drama.category]) {
for (final part in value.split(_tagSplitter)) {
final tag = part.trim();
if (tag.isEmpty || result.contains(tag)) continue;
result.add(tag);
}
}
return result.take(3).toList(growable: false);
}
}
class HomeFeedActionRail extends StatelessWidget {
const HomeFeedActionRail({
super.key,
required this.saved,
required this.liked,
required this.onSave,
required this.onLike,
required this.onEpisodes,
this.favoriteCount,
this.likeCount,
});
final bool saved;
final bool liked;
final int? favoriteCount;
final int? likeCount;
final VoidCallback onSave;
final VoidCallback onLike;
final VoidCallback onEpisodes;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
width: _actionWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_action(
key: const Key('rail-save'),
icon: saved
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
label: favoriteCount == null
? l10n.favorite
: formatInteractionCount(favoriteCount!),
labelKey: const Key('rail-save-count'),
onTap: onSave,
),
const SizedBox(height: _actionGap),
_action(
key: const Key('rail-like'),
icon: liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
label: likeCount == null
? l10n.like
: formatInteractionCount(likeCount!),
labelKey: const Key('rail-like-count'),
onTap: onLike,
),
const SizedBox(height: _actionGap),
_action(
key: const Key('rail-episodes'),
icon: Icons.layers_rounded,
label: l10n.episodes,
labelKey: const Key('rail-episodes-label'),
labelFontSize: 11,
onTap: onEpisodes,
),
],
),
);
}
Widget _action({
required Key key,
required IconData icon,
required String label,
required Key labelKey,
required VoidCallback onTap,
double labelFontSize = 12,
}) {
return GestureDetector(
key: key,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: _actionIcon,
color: AppColors.textPrimary,
shadows: const [
Shadow(
color: AppColors.shadowSoft,
blurRadius: 4,
offset: Offset(0, 1),
),
],
),
const SizedBox(height: 2),
Text(
label,
key: labelKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textPrimary,
fontSize: labelFontSize,
fontWeight: FontWeight.w500,
height: 1,
shadows: _textShadows,
),
),
],
),
);
}
}