feat(recommend): 新增推荐频道页并恢复首页沉浸式信息流
- tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类 - 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格 - 分类内容支持懒加载、下拉刷新与上拉分页加载 - 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色 - 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口) - l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案
This commit is contained in:
1 parent
a3f3481f4f
commit
c027acc91f
22 files changed
+2018
-318
No files matched your search
@@ -1200,6 +1200,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
unawaited(_presentFullPlayer(context, index));
|
||||
},
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onOpenSearch: widget.onSearch,
|
||||
onMore: _openCurrentMore,
|
||||
onScrubStart: videoReady
|
||||
? () => logic.beginScrub(videoPort!)
|
||||
|
||||
@@ -22,11 +22,13 @@ part 'home_feed_overlay_parts.dart';
|
||||
|
||||
const double _progressHeight = 2;
|
||||
const double _progressHitHeight = 32;
|
||||
const double _pageHorizontal = 14;
|
||||
const double _railRight = 8;
|
||||
const double _pageHorizontal = 20;
|
||||
const double _railRight = 14;
|
||||
const double _railGap = 12;
|
||||
const double _bottomInfoInset = 32;
|
||||
const double _railBottomOffset = 60;
|
||||
// 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。
|
||||
// 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。
|
||||
const double _bottomInfoInset = homeFeedWatchAllHeight + 16;
|
||||
const double _railBottomOffset = 40;
|
||||
const Duration _overlayFadeDuration = Duration(milliseconds: 120);
|
||||
const List<Shadow> _boostTextShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
|
||||
@@ -52,6 +54,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
required this.onDetail,
|
||||
required this.onOpenFullPlayer,
|
||||
this.onEpisodes,
|
||||
this.onOpenSearch,
|
||||
required this.onTogglePlay,
|
||||
required this.onBoostStart,
|
||||
required this.onBoostEnd,
|
||||
@@ -92,6 +95,9 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
final VoidCallback onDetail;
|
||||
final VoidCallback onOpenFullPlayer;
|
||||
final VoidCallback? onEpisodes;
|
||||
|
||||
/// 底部热搜胶囊点击(降级版:跳搜索页)。
|
||||
final VoidCallback? onOpenSearch;
|
||||
final VoidCallback onTogglePlay;
|
||||
final VoidCallback onBoostStart;
|
||||
final VoidCallback onBoostEnd;
|
||||
@@ -173,7 +179,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 20,
|
||||
bottom: homeFeedWatchAllHeight + 12,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.boosting ? 1 : 0,
|
||||
@@ -184,9 +190,9 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: _pageHorizontal,
|
||||
right: _pageHorizontal,
|
||||
bottom: homeFeedWatchAllHeight,
|
||||
child: _FeedProgressBar(
|
||||
controller: widget.controller,
|
||||
locked: widget.locked,
|
||||
@@ -203,6 +209,24 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
onSeek: widget.onSeek,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.visible && !_scrubbing && !widget.boosting
|
||||
? 1
|
||||
: 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.visible || _scrubbing || widget.boosting,
|
||||
child: HomeFeedWatchAllBar(
|
||||
drama: widget.drama,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -273,7 +297,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
onSave: widget.onSave,
|
||||
onLike: widget.onLike,
|
||||
onComments: widget.onComments,
|
||||
onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer,
|
||||
onShare: widget.onShare,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,6 +307,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
episode: widget.episode,
|
||||
onDetail: widget.onDetail,
|
||||
onOpenFullPlayer: widget.onOpenFullPlayer,
|
||||
onOpenSearch: widget.onOpenSearch,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ const double _thumbSize = 6;
|
||||
const double _thumbActiveSize = 14;
|
||||
const double _trackActiveHeight = 6;
|
||||
const double _lineCenterFromBottom = 8;
|
||||
const Duration _trackResizeDuration = Duration(milliseconds: 120);
|
||||
const double _timeBubbleWidth = 96;
|
||||
const double _timeBubbleMargin = 12;
|
||||
const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
|
||||
@@ -371,10 +370,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedContainer(
|
||||
child: Container(
|
||||
key: const Key('home-feed-progress-track'),
|
||||
duration: _trackResizeDuration,
|
||||
curve: Curves.easeOut,
|
||||
width: width,
|
||||
height: trackHeight,
|
||||
color: AppColors.border,
|
||||
@@ -382,10 +379,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedContainer(
|
||||
child: Container(
|
||||
key: const Key('home-feed-progress-played'),
|
||||
duration: dragging ? Duration.zero : _trackResizeDuration,
|
||||
curve: Curves.easeOut,
|
||||
width: playedWidth,
|
||||
height: trackHeight,
|
||||
color: AppColors.blue,
|
||||
@@ -401,10 +396,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
child: SizedBox.square(
|
||||
dimension: _thumbActiveSize,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
child: Container(
|
||||
key: const Key('home-feed-progress-thumb'),
|
||||
duration: _trackResizeDuration,
|
||||
curve: Curves.easeOut,
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
@@ -11,15 +10,22 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../home/widgets/home_drama_classification.dart';
|
||||
import 'interaction_rail.dart';
|
||||
|
||||
const double _seriesFont = 19;
|
||||
const double _tagFont = 13;
|
||||
const double _synopsisFont = 13;
|
||||
const double _seriesFont = 21;
|
||||
const double _tagFont = 14;
|
||||
const double _synopsisFont = 15;
|
||||
const double _watchAllHeight = 44;
|
||||
const List<Shadow> _textShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
];
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。
|
||||
const double homeFeedWatchAllHeight = _watchAllHeight;
|
||||
|
||||
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
|
||||
String _compactViewers(int count, Locale locale) =>
|
||||
NumberFormat.compact(locale: locale.languageCode).format(count);
|
||||
|
||||
class HomeFeedInfoPanel extends StatefulWidget {
|
||||
const HomeFeedInfoPanel({
|
||||
super.key,
|
||||
@@ -27,6 +33,7 @@ class HomeFeedInfoPanel extends StatefulWidget {
|
||||
required this.episode,
|
||||
required this.onDetail,
|
||||
required this.onOpenFullPlayer,
|
||||
this.onOpenSearch,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
@@ -34,6 +41,9 @@ class HomeFeedInfoPanel extends StatefulWidget {
|
||||
final VoidCallback onDetail;
|
||||
final VoidCallback onOpenFullPlayer;
|
||||
|
||||
/// 热搜胶囊点击;为空或播放量为 0 时不显示胶囊。
|
||||
final VoidCallback? onOpenSearch;
|
||||
|
||||
@override
|
||||
State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState();
|
||||
}
|
||||
@@ -48,9 +58,22 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
||||
final genres = _genreValues(Localizations.localeOf(context).languageCode);
|
||||
return Column(
|
||||
key: const Key('home-feed-info-panel'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.onOpenSearch != null && widget.drama.viewCount > 0) ...[
|
||||
_HotSearchPill(
|
||||
label: l10n.feedWatchingCount(
|
||||
_compactViewers(
|
||||
widget.drama.viewCount,
|
||||
Localizations.localeOf(context),
|
||||
),
|
||||
),
|
||||
onTap: widget.onOpenSearch!,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
GestureDetector(
|
||||
key: const Key('home-feed-series-name'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -67,12 +90,12 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _seriesFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 20,
|
||||
@@ -81,119 +104,96 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
_tagMetaRow(l10n, genres),
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_synopsis(l10n, synopsis),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_watchAll(context, l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
|
||||
return Row(
|
||||
final chips = <Widget>[
|
||||
if (isHotDrama(widget.drama))
|
||||
_TagChip(
|
||||
key: const Key('home-feed-hot-badge'),
|
||||
label: l10n.homeBadgeHot,
|
||||
filled: true,
|
||||
),
|
||||
for (final genre in genres)
|
||||
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
|
||||
];
|
||||
return SizedBox(
|
||||
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: 8),
|
||||
],
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Text(
|
||||
genres.join(' '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.overlayLabel,
|
||||
fontSize: _tagFont,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.favoriteEpisodeProgress(
|
||||
widget.episode.number,
|
||||
widget.drama.episodeCount,
|
||||
),
|
||||
key: const Key('home-feed-episode-progress'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.overlayLabel,
|
||||
fontSize: _tagFont,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
height: 25,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: chips.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, index) => chips[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsis(AppLocalizations l10n, String synopsis) {
|
||||
const style = TextStyle(
|
||||
color: AppColors.overlaySecondary,
|
||||
final prefix = l10n.feedEpisodePrefix(widget.episode.number);
|
||||
final baseStyle = const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
height: 1.35,
|
||||
height: 1.3,
|
||||
shadows: _textShadows,
|
||||
);
|
||||
final span = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: prefix,
|
||||
style: baseStyle.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextSpan(text: synopsis, style: baseStyle),
|
||||
],
|
||||
);
|
||||
if (_synopsisExpanded) {
|
||||
return _expandedSynopsis(span, baseStyle, l10n);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final toggle = _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.expandSynopsis,
|
||||
);
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: synopsis, style: style),
|
||||
maxLines: 2,
|
||||
text: span,
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final overflows = painter.didExceedMaxLines;
|
||||
if (!overflows) {
|
||||
return Text(
|
||||
synopsis,
|
||||
if (!painter.didExceedMaxLines) {
|
||||
return Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
style: baseStyle,
|
||||
);
|
||||
}
|
||||
if (_synopsisExpanded) {
|
||||
return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n);
|
||||
}
|
||||
return Stack(
|
||||
// 设计稿:单行省略号 + 行内固定「展开」。
|
||||
return Row(
|
||||
key: const Key('home-feed-synopsis-collapsed'),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 40),
|
||||
child: Text(
|
||||
synopsis,
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.expandSynopsis,
|
||||
style: baseStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
toggle,
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -201,26 +201,28 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
}
|
||||
|
||||
Widget _expandedSynopsis(
|
||||
double maxWidth,
|
||||
String synopsis,
|
||||
InlineSpan span,
|
||||
TextStyle style,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(synopsis, key: const Key('home-feed-synopsis'), style: style),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.showLess,
|
||||
),
|
||||
return Column(
|
||||
key: const Key('home-feed-synopsis-expanded'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
style: style,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.showLess,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,54 +236,12 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _watchAll(BuildContext context, AppLocalizations l10n) {
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final buttonWidth = math.min(screenWidth * 0.7, constraints.maxWidth);
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: buttonWidth,
|
||||
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(String languageCode) {
|
||||
final localized = widget.drama.localizedTypeLabels(languageCode);
|
||||
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
||||
@@ -297,6 +257,147 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。
|
||||
class _HotSearchPill extends StatelessWidget {
|
||||
const _HotSearchPill({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-hot-search-pill'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.search_rounded,
|
||||
size: 15,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 热剧红胶囊 / 类型深色半透明填充胶囊。
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({super.key, required this.label, this.filled = false});
|
||||
|
||||
final String label;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? AppColors.brandRed
|
||||
: AppColors.textPrimary.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _tagFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部通栏条:🎬 观看完整剧集 全 N 集。
|
||||
class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
const HomeFeedWatchAllBar({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_rounded,
|
||||
size: 22,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeFeedActionRail extends StatelessWidget {
|
||||
const HomeFeedActionRail({
|
||||
super.key,
|
||||
@@ -305,7 +406,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
required this.onSave,
|
||||
required this.onLike,
|
||||
required this.onComments,
|
||||
required this.onEpisodes,
|
||||
required this.onShare,
|
||||
this.favoriteCount,
|
||||
this.likeCount,
|
||||
this.commentCount,
|
||||
@@ -319,13 +420,17 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onShare;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return InteractionRail(
|
||||
itemGap: 14,
|
||||
itemGap: 18,
|
||||
buttonSize: 48,
|
||||
iconSize: 36,
|
||||
countFontSize: 15,
|
||||
iconCountGap: 6,
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-save'),
|
||||
@@ -334,18 +439,9 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
count: favoriteCount,
|
||||
countKey: const Key('rail-save-count'),
|
||||
selected: saved,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: onSave,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-like'),
|
||||
asset: 'assets/icon/rail/rail_like.png',
|
||||
label: l10n.like,
|
||||
count: likeCount,
|
||||
countKey: const Key('rail-like-count'),
|
||||
selected: liked,
|
||||
selectedColor: AppColors.doubleTapHeartRed,
|
||||
onTap: onLike,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-comments'),
|
||||
asset: 'assets/icon/rail/rail_comment.png',
|
||||
@@ -355,11 +451,22 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
onTap: onComments,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-episodes'),
|
||||
icon: Icons.layers_rounded,
|
||||
label: l10n.episodes,
|
||||
countKey: const Key('rail-episodes-label'),
|
||||
onTap: onEpisodes,
|
||||
key: const Key('rail-like'),
|
||||
asset: 'assets/icon/rail/rail_like.png',
|
||||
label: l10n.like,
|
||||
count: likeCount,
|
||||
countKey: const Key('rail-like-count'),
|
||||
selected: liked,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: onLike,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.reply_rounded,
|
||||
mirrorIcon: true,
|
||||
label: l10n.share,
|
||||
countKey: const Key('rail-share-label'),
|
||||
onTap: onShare,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,8 +5,10 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const double _buttonSize = 44;
|
||||
const double _iconSize = 32;
|
||||
const double _defaultButtonSize = 44;
|
||||
const double _defaultIconSize = 32;
|
||||
const double _defaultCountFont = 12;
|
||||
const double _defaultIconCountGap = 2;
|
||||
const double _defaultItemGap = 24;
|
||||
|
||||
/// 互动栏单项:图标、计数与点击。
|
||||
@@ -21,6 +23,7 @@ class InteractionRailItem {
|
||||
this.countKey,
|
||||
this.selected = false,
|
||||
this.selectedColor = AppColors.blue,
|
||||
this.mirrorIcon = false,
|
||||
}) : assert((asset == null) != (icon == null));
|
||||
|
||||
final Key key;
|
||||
@@ -32,6 +35,9 @@ class InteractionRailItem {
|
||||
final Key? countKey;
|
||||
final bool selected;
|
||||
final Color selectedColor;
|
||||
|
||||
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
|
||||
final bool mirrorIcon;
|
||||
}
|
||||
|
||||
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
|
||||
@@ -40,10 +46,18 @@ class InteractionRail extends StatelessWidget {
|
||||
super.key,
|
||||
required this.items,
|
||||
this.itemGap = _defaultItemGap,
|
||||
this.buttonSize = _defaultButtonSize,
|
||||
this.iconSize = _defaultIconSize,
|
||||
this.countFontSize = _defaultCountFont,
|
||||
this.iconCountGap = _defaultIconCountGap,
|
||||
});
|
||||
|
||||
final List<InteractionRailItem> items;
|
||||
final double itemGap;
|
||||
final double buttonSize;
|
||||
final double iconSize;
|
||||
final double countFontSize;
|
||||
final double iconCountGap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -52,7 +66,13 @@ class InteractionRail extends StatelessWidget {
|
||||
children: [
|
||||
for (var index = 0; index < items.length; index++) ...[
|
||||
if (index > 0) SizedBox(height: itemGap),
|
||||
_RailButton(item: items[index]),
|
||||
_RailButton(
|
||||
item: items[index],
|
||||
buttonSize: buttonSize,
|
||||
iconSize: iconSize,
|
||||
countFontSize: countFontSize,
|
||||
iconCountGap: iconCountGap,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -60,53 +80,61 @@ class InteractionRail extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _RailButton extends StatelessWidget {
|
||||
const _RailButton({required this.item});
|
||||
const _RailButton({
|
||||
required this.item,
|
||||
required this.buttonSize,
|
||||
required this.iconSize,
|
||||
required this.countFontSize,
|
||||
required this.iconCountGap,
|
||||
});
|
||||
|
||||
final InteractionRailItem item;
|
||||
final double buttonSize;
|
||||
final double iconSize;
|
||||
final double countFontSize;
|
||||
final double iconCountGap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
|
||||
return Column(
|
||||
key: item.key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: _buttonSize,
|
||||
dimension: buttonSize,
|
||||
child: IconButton(
|
||||
onPressed: item.onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: item.asset != null
|
||||
? Image.asset(
|
||||
item.asset!,
|
||||
width: _iconSize,
|
||||
height: _iconSize,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
fit: BoxFit.contain,
|
||||
color: item.selected
|
||||
? item.selectedColor
|
||||
: AppColors.onPrimary,
|
||||
color: tint,
|
||||
colorBlendMode: BlendMode.srcIn,
|
||||
)
|
||||
: Icon(
|
||||
item.icon,
|
||||
size: _iconSize,
|
||||
color: item.selected
|
||||
? item.selectedColor
|
||||
: AppColors.onPrimary,
|
||||
: Transform.flip(
|
||||
flipX: item.mirrorIcon,
|
||||
child: Icon(item.icon, size: iconSize, color: tint),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SizedBox(height: iconCountGap),
|
||||
Text(
|
||||
item.count == null ? item.label : formatInteractionCount(item.count!),
|
||||
key: item.countKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
height: 1,
|
||||
fontSize: 12,
|
||||
fontSize: countFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user