445 lines
13 KiB
Dart
445 lines
13 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:math' as math;
|
|
|
|
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 = 19;
|
|
const double _tagFont = 13;
|
|
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 = _headline(l10n, dramaTitle, episodeTitle);
|
|
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
|
final genres = _genreValues();
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
GestureDetector(
|
|
key: const Key('home-feed-title-open'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: widget.onOpenFullPlayer,
|
|
child: 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),
|
|
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.w600,
|
|
shadows: _textShadows,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 20,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_tagMetaRow(l10n, genres),
|
|
if (synopsis.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
_synopsis(l10n, synopsis),
|
|
],
|
|
const SizedBox(height: 12),
|
|
_watchAll(context, l10n),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _headline(
|
|
AppLocalizations l10n,
|
|
String dramaTitle,
|
|
String episodeTitle,
|
|
) {
|
|
if (episodeTitle.isEmpty) return dramaTitle;
|
|
final normalized = episodeTitle.toLowerCase().replaceAll(' ', '');
|
|
final localizedEpisode = l10n
|
|
.episodeLabel(widget.episode.number)
|
|
.toLowerCase()
|
|
.replaceAll(' ', '');
|
|
final episodeOnly = RegExp(r'^(第?\d+集|episode\d+)$').hasMatch(normalized);
|
|
return normalized == localizedEpisode || episodeOnly
|
|
? dramaTitle
|
|
: episodeTitle;
|
|
}
|
|
|
|
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: 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),
|
|
Text(
|
|
l10n.favoriteEpisodeProgress(
|
|
widget.episode.number,
|
|
widget.drama.episodeCount,
|
|
),
|
|
key: const Key('home-feed-episode-progress'),
|
|
maxLines: 1,
|
|
softWrap: false,
|
|
style: const TextStyle(
|
|
color: AppColors.overlayLabel,
|
|
fontSize: _tagFont,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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.expandSynopsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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(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() {
|
|
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,
|
|
iconColor: liked
|
|
? AppColors.doubleTapHeartRed
|
|
: AppColors.textPrimary,
|
|
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,
|
|
Color iconColor = AppColors.textPrimary,
|
|
double labelFontSize = 12,
|
|
}) {
|
|
return GestureDetector(
|
|
key: key,
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: _actionIcon,
|
|
color: iconColor,
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|