// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; const double _columnGap = 8; const double _cardGap = 14; const double _gridHorizontalPadding = 12; const double _coverAspectRatio = 3 / 4; const double _coverRadius = AppDimens.coverRadius; const double _titleGap = 8; const double _tagsGap = 6; const double _titleFontSize = 15; const double _tagChipRadius = 6; const double _tagChipFontSize = 11; const double _heatFontSize = 9; const double _heatIconSize = 11; const int _maxTags = 3; const Color _heatBackground = AppColors.overlayMedium; const Color _tagChipBackground = AppColors.raised; final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 剧场/分类页共用的双列剧目网格。 /// /// 按行懒加载:翻到第 N 页时只构建当前可见的几张卡,长列表不会把整本目录都留在树上。 class SliverDramaGrid extends StatelessWidget { const SliverDramaGrid({ super.key, required this.l10n, required this.dramas, required this.onOpen, }); final AppLocalizations l10n; final List dramas; final ValueChanged onOpen; @override Widget build(BuildContext context) { final rowCount = (dramas.length + 1) ~/ 2; return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, row) { final left = dramas[row * 2]; final rightIndex = row * 2 + 1; final right = rightIndex < dramas.length ? dramas[rightIndex] : null; return Padding( // 行间距沿用卡片间距,首行不需要。 padding: EdgeInsets.only(top: row == 0 ? 0 : _cardGap), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: _card(left)), const SizedBox(width: _columnGap), Expanded( child: right == null ? const SizedBox.shrink() : _card(right), ), ], ), ); }, childCount: rowCount), ), ); } Widget _card(Drama drama) { return DramaGridCard( l10n: l10n, drama: drama, onTap: () => onOpen(drama), ); } } /// 双列网格中的剧目卡片:海报 + 标题 + 题材标签。 class DramaGridCard extends StatelessWidget { const DramaGridCard({ super.key, required this.l10n, required this.drama, required this.onTap, }); final AppLocalizations l10n; final Drama drama; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( key: Key('home-drama-card-${drama.id}'), behavior: HitTestBehavior.opaque, onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _cover(), const SizedBox(height: _titleGap), _title(context), const SizedBox(height: _tagsGap), _tags(context), ], ), ); } Widget _cover() { return ClipRRect( key: Key('home-drama-cover-${drama.id}'), borderRadius: BorderRadius.circular(_coverRadius), child: AspectRatio( aspectRatio: _coverAspectRatio, child: Stack( fit: StackFit.expand, children: [ _DiscoverCoverImage(path: drama.posterUrl), Positioned(left: 8, bottom: 8, child: _heatBadge()), ], ), ), ); } Widget _heatBadge() { return Container( key: Key('drama-heat-${drama.id}'), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: _heatBackground, borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.local_fire_department_rounded, color: AppColors.onPrimary, size: _heatIconSize, ), const SizedBox(width: 2), Text( l10n.heatLabel(formatInteractionCount(drama.viewCount)), style: const TextStyle( color: AppColors.onPrimary, fontSize: _heatFontSize, fontWeight: FontWeight.w600, ), ), ], ), ); } Widget _title(BuildContext context) { return Text( drama.localizedTitle(Localizations.localeOf(context).languageCode), key: Key('home-drama-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: _titleFontSize, fontWeight: FontWeight.w700, height: 1.25, ), ); } Widget _tags(BuildContext context) { final tags = _tagValues(context); if (tags.isEmpty) return const SizedBox.shrink(); return Wrap( spacing: 6, runSpacing: 4, children: [ for (var index = 0; index < tags.length; index++) Container( key: Key('drama-tag-chip-${drama.id}-$index'), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: _tagChipBackground, borderRadius: BorderRadius.circular(_tagChipRadius), ), child: Text( tags[index], style: const TextStyle( color: AppColors.muted, fontSize: _tagChipFontSize, ), ), ), ], ); } List _tagValues(BuildContext context) { final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); if (localized.isNotEmpty) return localized.take(3).toList(growable: false); final result = []; for (final value in [drama.genre, drama.category]) { for (final part in value.split(_tagSplitter)) { final tag = part.trim(); if (tag.isEmpty || result.contains(tag)) continue; result.add(tag); if (result.length == _maxTags) return result; } } return result; } } class _DiscoverCoverImage extends StatelessWidget { const _DiscoverCoverImage({required this.path}); final String path; @override Widget build(BuildContext context) { final url = AppConfig.mediaUrl(path); if (url.isEmpty) return _placeholder(); return LayoutBuilder( builder: (context, constraints) { // 卡片只有半屏宽,按原图解码一张竖版海报就是数 MB,按布局宽度限一下。 final decodeWidth = constraints.hasBoundedWidth ? resolveImageDecodePixels( constraints.maxWidth, MediaQuery.devicePixelRatioOf(context), ) : null; return Image.network( url, width: double.infinity, height: double.infinity, fit: BoxFit.cover, cacheWidth: decodeWidth, loadingBuilder: (context, child, progress) { if (progress == null) return child; return _placeholder(); }, errorBuilder: (_, _, _) => _placeholder(), ); }, ); } Widget _placeholder() { return const ColoredBox( color: AppColors.raised, child: Center( child: Icon( Icons.image_not_supported_outlined, color: AppColors.subtle, size: 32, ), ), ); } }