329 lines
9.8 KiB
Dart
329 lines
9.8 KiB
Dart
// Author: fruit.com.cn
|
|
// 职责:推荐视频底部标题、标签、集数、简介与观看全集入口。
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../../../core/data/models/drama.dart';
|
|
import '../../../core/data/models/locale_text.dart';
|
|
import '../../../core/formatting/localized_content.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/widgets/app_press_scale.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
import 'expandable_synopsis.dart';
|
|
import 'interaction_rail.dart';
|
|
|
|
part 'home_feed_info_tags.dart';
|
|
|
|
const double _seriesFont = 18;
|
|
const double _tagFont = 13;
|
|
const double _watchAllHeight = 38;
|
|
const double _titleMetadataGap = 2;
|
|
const double _metadataSynopsisGap = 2;
|
|
const double _hotSearchBottomGap = 2;
|
|
const double _metadataGap = 8;
|
|
const int _maximumTags = 2;
|
|
const double _episodeFont = 14;
|
|
const double _episodeWidthFraction = 0.55;
|
|
const double _metadataLineHeight = 1.4;
|
|
const _metadataStrutStyle = StrutStyle(
|
|
fontSize: _episodeFont,
|
|
height: _metadataLineHeight,
|
|
forceStrutHeight: true,
|
|
);
|
|
|
|
/// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
|
|
const double _watchAllInset = 12;
|
|
const double _watchAllRadius = 6;
|
|
const double _watchAllFont = 16;
|
|
|
|
/// 「观看完整剧集」按钮高度,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> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
|
|
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
|
final types = _typeLabels(Localizations.localeOf(context).languageCode);
|
|
final tags = _tagChips(types);
|
|
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: _hotSearchBottomGap),
|
|
],
|
|
_title(dramaTitle),
|
|
const SizedBox(height: _titleMetadataGap),
|
|
_metadata(l10n, tags),
|
|
if (synopsis.isNotEmpty) ...[
|
|
const SizedBox(height: _metadataSynopsisGap),
|
|
ExpandableSynopsis(
|
|
key: ValueKey(widget.drama.id),
|
|
synopsis: synopsis,
|
|
textColor: AppColors.homeFeedVideoCopy,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _title(String dramaTitle) => 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.w400,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 18,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
List<Widget> _tagChips(List<String> types) {
|
|
return [
|
|
for (final type in types)
|
|
_TagChip(key: Key('home-feed-tag-$type'), label: type),
|
|
];
|
|
}
|
|
|
|
Widget _metadata(AppLocalizations l10n, List<Widget> tags) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final episode = Text(
|
|
widget.drama.episodeCount > 0
|
|
? l10n.episodeOfTotal(
|
|
widget.episode.number,
|
|
widget.drama.episodeCount,
|
|
)
|
|
: l10n.feedEpisodePrefix(widget.episode.number),
|
|
key: const Key('home-feed-episode-meta'),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
strutStyle: _metadataStrutStyle,
|
|
style: const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: _episodeFont,
|
|
height: _metadataLineHeight,
|
|
),
|
|
);
|
|
return Row(
|
|
key: const Key('home-feed-metadata'),
|
|
children: [
|
|
if (tags.isNotEmpty) ...[
|
|
Flexible(child: _TagStrip(chips: tags)),
|
|
const SizedBox(width: _metadataGap),
|
|
],
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: tags.isEmpty
|
|
? constraints.maxWidth
|
|
: constraints.maxWidth * _episodeWidthFraction,
|
|
),
|
|
child: episode,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<String> _typeLabels(String languageCode) => widget.drama.types
|
|
.take(_maximumTags)
|
|
.map((type) => localizedText(languageCode, '', type).trim())
|
|
.where((label) => label.isNotEmpty)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
/// 左侧「观看全集」条:与信息区等宽,居中文字避免与右侧互动栏争抢。
|
|
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 ClipRRect(
|
|
borderRadius: BorderRadius.circular(_watchAllRadius),
|
|
child: DecoratedBox(
|
|
key: const Key('home-feed-full-series-surface'),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.watchAllSurface,
|
|
border: Border.fromBorderSide(
|
|
BorderSide(color: AppColors.watchAllBorder),
|
|
),
|
|
),
|
|
child: Material(
|
|
key: const Key('home-feed-full-series-bg'),
|
|
color: AppColors.transparent,
|
|
child: InkWell(
|
|
key: const Key('home-feed-full-series'),
|
|
onTap: onTap,
|
|
child: AppPressScale(child: _content(l10n)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _content(AppLocalizations l10n) => SizedBox(
|
|
width: double.infinity,
|
|
height: _watchAllHeight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
|
|
child: Center(
|
|
child: Text(
|
|
l10n.homeWatchAll,
|
|
semanticsLabel: l10n.watchFullSeries('${drama.episodeCount}'),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: _watchAllFont,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: interactionRailCompactGap,
|
|
buttonSize: 48,
|
|
iconSize: 36,
|
|
countFontSize: 15,
|
|
iconCountGap: 2,
|
|
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,
|
|
animateTap: true,
|
|
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,
|
|
animateTap: true,
|
|
selectedColor: AppColors.brandRed,
|
|
onTap: onLike,
|
|
),
|
|
InteractionRailItem(
|
|
key: const Key('rail-share'),
|
|
asset: 'assets/icon/rail/rail_share.png',
|
|
label: l10n.share,
|
|
countKey: const Key('rail-share-label'),
|
|
onTap: onShare,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|