- tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类 - 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格 - 分类内容支持懒加载、下拉刷新与上拉分页加载 - 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色 - 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口) - l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案
475 lines
14 KiB
Dart
475 lines
14 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../../../core/data/models/drama.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';
|
|
import 'interaction_rail.dart';
|
|
|
|
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,
|
|
required this.drama,
|
|
required this.episode,
|
|
required this.onDetail,
|
|
required this.onOpenFullPlayer,
|
|
this.onOpenSearch,
|
|
});
|
|
|
|
final Drama drama;
|
|
final Episode episode;
|
|
final VoidCallback onDetail;
|
|
final VoidCallback onOpenFullPlayer;
|
|
|
|
/// 热搜胶囊点击;为空或播放量为 0 时不显示胶囊。
|
|
final VoidCallback? onOpenSearch;
|
|
|
|
@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 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,
|
|
onTap: widget.onOpenFullPlayer,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
dramaTitle,
|
|
key: const Key('home-feed-series-title'),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: _seriesFont,
|
|
fontWeight: FontWeight.w700,
|
|
shadows: _textShadows,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 20,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
_tagMetaRow(l10n, genres),
|
|
if (synopsis.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
_synopsis(l10n, synopsis),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
|
|
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'),
|
|
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) {
|
|
final prefix = l10n.feedEpisodePrefix(widget.episode.number);
|
|
final baseStyle = const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: _synopsisFont,
|
|
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: span,
|
|
maxLines: 1,
|
|
textDirection: Directionality.of(context),
|
|
textScaler: MediaQuery.textScalerOf(context),
|
|
)..layout(maxWidth: constraints.maxWidth);
|
|
if (!painter.didExceedMaxLines) {
|
|
return Text.rich(
|
|
span,
|
|
key: const Key('home-feed-synopsis'),
|
|
maxLines: 1,
|
|
style: baseStyle,
|
|
);
|
|
}
|
|
// 设计稿:单行省略号 + 行内固定「展开」。
|
|
return Row(
|
|
key: const Key('home-feed-synopsis-collapsed'),
|
|
children: [
|
|
Expanded(
|
|
child: Text.rich(
|
|
span,
|
|
key: const Key('home-feed-synopsis'),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: baseStyle,
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
toggle,
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _expandedSynopsis(
|
|
InlineSpan span,
|
|
TextStyle style,
|
|
AppLocalizations l10n,
|
|
) {
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
shadows: _textShadows,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<String> _genreValues(String languageCode) {
|
|
final localized = widget.drama.localizedTypeLabels(languageCode);
|
|
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// 热搜榜降级胶囊:🔍 共 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,
|
|
required this.saved,
|
|
required this.liked,
|
|
required this.onSave,
|
|
required this.onLike,
|
|
required this.onComments,
|
|
required this.onShare,
|
|
this.favoriteCount,
|
|
this.likeCount,
|
|
this.commentCount,
|
|
});
|
|
|
|
final bool saved;
|
|
final bool liked;
|
|
final int? favoriteCount;
|
|
final int? likeCount;
|
|
final int? commentCount;
|
|
final VoidCallback onSave;
|
|
final VoidCallback onLike;
|
|
final VoidCallback onComments;
|
|
final VoidCallback onShare;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return InteractionRail(
|
|
itemGap: 18,
|
|
buttonSize: 48,
|
|
iconSize: 36,
|
|
countFontSize: 15,
|
|
iconCountGap: 6,
|
|
items: [
|
|
InteractionRailItem(
|
|
key: const Key('rail-save'),
|
|
asset: 'assets/icon/rail/rail_save.png',
|
|
label: l10n.favorite,
|
|
count: favoriteCount,
|
|
countKey: const Key('rail-save-count'),
|
|
selected: saved,
|
|
selectedColor: AppColors.brandRed,
|
|
onTap: onSave,
|
|
),
|
|
InteractionRailItem(
|
|
key: const Key('rail-comments'),
|
|
asset: 'assets/icon/rail/rail_comment.png',
|
|
label: l10n.comments,
|
|
count: commentCount,
|
|
countKey: const Key('rail-comments-count'),
|
|
onTap: onComments,
|
|
),
|
|
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.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,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|