261 lines
8.7 KiB
Dart
261 lines
8.7 KiB
Dart
// Author: fruit.com.cn
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../../core/data/models/drama.dart';
|
||
import '../../../core/theme/app_colors.dart';
|
||
import '../../../core/theme/app_dimens.dart';
|
||
import '../../../core/widgets/remote_image.dart';
|
||
import '../../../l10n/app_localizations.dart';
|
||
|
||
/// 设计稿(首页 1)推荐页内容区规格:
|
||
/// 海报卡 110×157、页边距 14、卡距 9、圆角 12。
|
||
const double _pageSide = 14;
|
||
const double _cardWidth = 110;
|
||
const double _cardCoverHeight = 157;
|
||
const double _cardGap = 9;
|
||
const double _cardRadius = AppDimens.coverRadius;
|
||
|
||
/// 海报卡封面以下的高度:上间距 8 + 两行剧名 + 2 + 集数行 + 余量。
|
||
///
|
||
/// 横向列表和网格都要给一个有限的卡片高度,这里是唯一来源,
|
||
/// 两行剧名撑高时只改这一处,不会出现某个入口高度不够而溢出。
|
||
const double homePosterCardBodyExtent = 62;
|
||
|
||
/// 上新横滑卡板块。无板块标题,卡片左上角带「上新」角标。
|
||
/// 内层横向 ListView 会自行识别横向手势,不与外层纵向滚动冲突。
|
||
class HomeNewArrivalRow extends StatelessWidget {
|
||
const HomeNewArrivalRow({
|
||
super.key,
|
||
required this.dramas,
|
||
required this.onOpen,
|
||
});
|
||
|
||
final List<Drama> dramas;
|
||
final ValueChanged<Drama> onOpen;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (dramas.isEmpty) return const SizedBox.shrink();
|
||
return SizedBox(
|
||
key: const Key('home-new-arrival-row'),
|
||
height: _cardCoverHeight + homePosterCardBodyExtent,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: _pageSide),
|
||
itemCount: dramas.length,
|
||
separatorBuilder: (_, _) => const SizedBox(width: _cardGap),
|
||
itemBuilder: (context, index) => SizedBox(
|
||
width: _cardWidth,
|
||
child: HomePosterCard(
|
||
key: Key('home-new-arrival-${dramas[index].id}'),
|
||
drama: dramas[index],
|
||
badge: AppLocalizations.of(context)!.newArrivalBadge,
|
||
onTap: () => onOpen(dramas[index]),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 110×157 海报 + 卡下剧名/集数。
|
||
///
|
||
/// 封面左上角标签优先用接口返回的短剧标签(`drama.tag` + `drama.tagColor`);
|
||
/// 接口没有标签时,若传了 [badge](如「上新」)则退回显示该角标。
|
||
class HomePosterCard extends StatelessWidget {
|
||
const HomePosterCard({
|
||
super.key,
|
||
required this.drama,
|
||
required this.onTap,
|
||
this.badge,
|
||
});
|
||
|
||
final Drama drama;
|
||
final VoidCallback onTap;
|
||
final String? badge;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final lang = Localizations.localeOf(context).languageCode;
|
||
final tag = drama.localizedTag(lang);
|
||
final tagLabel = tag.isNotEmpty ? tag : (badge ?? '');
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(_cardRadius),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(_cardRadius),
|
||
child: AspectRatio(
|
||
aspectRatio: _cardWidth / _cardCoverHeight,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
RemoteImage(
|
||
drama.posterUrl,
|
||
fit: BoxFit.cover,
|
||
fallbackColor: AppColors.contentSurface,
|
||
),
|
||
if (tagLabel.isNotEmpty)
|
||
Positioned(
|
||
left: 8,
|
||
top: 8,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8,
|
||
vertical: 3,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: tag.isNotEmpty
|
||
? drama.tagRibbonColor(AppColors.brandRed)
|
||
: AppColors.brandRed,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
tagLabel,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w400,
|
||
height: 1.1,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
drama.localizedTitle(lang),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
l10n.totalEpisodesShort(drama.episodeCount),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: AppColors.textTertiary,
|
||
fontSize: 12,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 编辑精选横幅:满宽背景图 + 左上小标签、大标题、副标题。
|
||
class HomeEditorPickBanner extends StatelessWidget {
|
||
const HomeEditorPickBanner({
|
||
super.key,
|
||
required this.drama,
|
||
required this.onOpen,
|
||
});
|
||
|
||
final Drama drama;
|
||
final ValueChanged<Drama> onOpen;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final lang = Localizations.localeOf(context).languageCode;
|
||
return Padding(
|
||
key: const Key('home-editor-pick'),
|
||
padding: const EdgeInsets.symmetric(horizontal: _pageSide),
|
||
child: SizedBox(
|
||
height: 72,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(_cardRadius),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
RemoteImage(
|
||
drama.posterUrl,
|
||
fit: BoxFit.cover,
|
||
fallbackColor: AppColors.contentSurface,
|
||
),
|
||
const DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.centerLeft,
|
||
end: Alignment.centerRight,
|
||
colors: [
|
||
Color(0xE6000000),
|
||
Color(0x4D000000),
|
||
Color(0x1A000000),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Positioned.fill(
|
||
child: Material(
|
||
color: AppColors.transparent,
|
||
child: InkWell(onTap: () => onOpen(drama)),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
l10n.editorPick,
|
||
maxLines: 1,
|
||
style: TextStyle(
|
||
color: AppColors.textPrimary.withValues(alpha: 0.85),
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w500,
|
||
letterSpacing: 2,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
drama.localizedTitle(lang),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
height: 1.15,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
drama.localizedSynopsis(lang),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: AppColors.textPrimary.withValues(alpha: 0.8),
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|