flutter-ci / test (push) Failing after 30m39s
Drama 新增 tagCode,推荐页只看 NEW_ARRIVAL 这个码决定上新横滑, 不再依赖可变的标签文案;分类网格高度改为复用卡片的统一常量。 头部轮播改用虚拟无限页码,首尾相接且能反向滑,自动播放不再倒滑回第一张。
425 lines
12 KiB
Dart
425 lines
12 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../../core/data/models/drama.dart';
|
|
import '../../../core/data/models/home_feed.dart';
|
|
import '../../../core/data/services/favorite_controller.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/widgets/remote_image.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
|
|
/// 推荐页顶部静态头图轮播:封面 + 居中剧名/简介 + 立即观看/收藏 + 轮播点。
|
|
class HomeHeroCarousel extends StatefulWidget {
|
|
const HomeHeroCarousel({
|
|
super.key,
|
|
required this.banners,
|
|
required this.dramaFor,
|
|
required this.onWatch,
|
|
this.active = true,
|
|
});
|
|
|
|
final List<FeedBanner> banners;
|
|
|
|
/// 按 banner 关联到的剧目;为空时只展示头图。
|
|
final Drama? Function(FeedBanner banner) dramaFor;
|
|
|
|
/// 点击「立即观看」或直接点按头图。
|
|
final ValueChanged<Drama> onWatch;
|
|
|
|
final bool active;
|
|
|
|
/// 固定轮播高度;后台按该比例(屏宽 : height)出图即可铺满不裁切。
|
|
static const double height = 420;
|
|
|
|
@override
|
|
State<HomeHeroCarousel> createState() => _HomeHeroCarouselState();
|
|
}
|
|
|
|
class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
|
static const Duration _autoInterval = Duration(milliseconds: 4500);
|
|
static const Duration _pageDuration = Duration(milliseconds: 480);
|
|
static const double _dotSize = 4;
|
|
static const double _dotGap = 9;
|
|
static const double _ctaHeight = 38;
|
|
|
|
/// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。
|
|
static const int _basePage = 1000;
|
|
|
|
late final PageController _controller;
|
|
Timer? _timer;
|
|
int _page = 0;
|
|
int _index = 0;
|
|
bool _pressed = false;
|
|
bool _tickerEnabled = true;
|
|
|
|
/// 起始页码取 banner 数量的大倍数,向左右滑都不会到头。
|
|
static int _initialPageOf(int bannerCount) =>
|
|
bannerCount > 1 ? bannerCount * _basePage : 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_page = _initialPageOf(widget.banners.length);
|
|
_controller = PageController(initialPage: _page);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer());
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final enabled = TickerMode.valuesOf(context).enabled;
|
|
if (enabled != _tickerEnabled) {
|
|
_tickerEnabled = enabled;
|
|
_syncTimer();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant HomeHeroCarousel oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.banners.length != widget.banners.length) {
|
|
// 首屏 banner 是异步到的:列表从空变有之后要把页码挪到虚拟起点,
|
|
// 否则左滑第一下就到头了。
|
|
_page = _initialPageOf(widget.banners.length);
|
|
_index = 0;
|
|
_jumpToBasePage();
|
|
}
|
|
if (oldWidget.banners.length != widget.banners.length ||
|
|
oldWidget.active != widget.active) {
|
|
_syncTimer();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _syncTimer() {
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
final canAdvance =
|
|
mounted &&
|
|
widget.active &&
|
|
_tickerEnabled &&
|
|
!_pressed &&
|
|
widget.banners.length > 1;
|
|
if (!canAdvance) return;
|
|
_timer = Timer.periodic(_autoInterval, (_) => _advance());
|
|
}
|
|
|
|
void _advance() {
|
|
if (!_controller.hasClients || widget.banners.length < 2) return;
|
|
// 虚拟页码无限,直接翻下一页即可,不用在末尾跳回第 0 页。
|
|
_controller.nextPage(duration: _pageDuration, curve: Curves.easeOutCubic);
|
|
}
|
|
|
|
/// 把 PageView 静默挪到虚拟起点:视觉上是同一张图,不会闪。
|
|
void _jumpToBasePage() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || !_controller.hasClients) return;
|
|
if (_controller.page?.round() != _page) _controller.jumpToPage(_page);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.banners.isEmpty) return const SizedBox.shrink();
|
|
return SizedBox(
|
|
key: const Key('home-hero-carousel'),
|
|
height: HomeHeroCarousel.height,
|
|
child: Listener(
|
|
onPointerDown: (_) {
|
|
_pressed = true;
|
|
_timer?.cancel();
|
|
},
|
|
onPointerUp: (_) {
|
|
_pressed = false;
|
|
_syncTimer();
|
|
},
|
|
onPointerCancel: (_) {
|
|
_pressed = false;
|
|
_syncTimer();
|
|
},
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
PageView.builder(
|
|
controller: _controller,
|
|
// 数量大于 1 时用无限页码做无缝循环,取模映射到真实 banner。
|
|
itemCount: widget.banners.length > 1
|
|
? null
|
|
: widget.banners.length,
|
|
onPageChanged: (value) => setState(() {
|
|
_page = value;
|
|
final count = widget.banners.length;
|
|
_index = count == 0 ? 0 : value % count;
|
|
}),
|
|
itemBuilder: (context, page) =>
|
|
_bannerPage(context, page % widget.banners.length),
|
|
),
|
|
Positioned(left: 0, right: 0, bottom: 14, child: _dots(context)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _bannerPage(BuildContext context, int index) {
|
|
final banner = widget.banners[index];
|
|
final drama = widget.dramaFor(banner);
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
RemoteImage(
|
|
banner.imageUrl,
|
|
fit: BoxFit.cover,
|
|
showFallbackIcon: true,
|
|
fallbackColor: AppColors.contentSurface,
|
|
),
|
|
const DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Color(0x99000000),
|
|
Color(0x00000000),
|
|
Color(0x66000000),
|
|
Color(0xB3000000),
|
|
],
|
|
stops: [0, 0.22, 0.62, 1],
|
|
),
|
|
),
|
|
),
|
|
if (drama != null)
|
|
Align(
|
|
alignment: const Alignment(0, 0.6),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
|
child: _HeroCopy(
|
|
key: Key('home-hero-copy-$index'),
|
|
drama: drama,
|
|
ctaHeight: _ctaHeight,
|
|
onWatch: () => widget.onWatch(drama),
|
|
),
|
|
),
|
|
),
|
|
if (drama != null)
|
|
Positioned.fill(
|
|
child: Material(
|
|
color: AppColors.transparent,
|
|
child: InkWell(
|
|
key: Key('home-hero-$index'),
|
|
highlightColor: AppColors.transparent,
|
|
onTap: () => widget.onWatch(drama),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _dots(BuildContext context) {
|
|
if (widget.banners.length < 2) return const SizedBox.shrink();
|
|
return ExcludeSemantics(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
for (var index = 0; index < widget.banners.length; index++)
|
|
Container(
|
|
key: Key('home-hero-dot-$index'),
|
|
width: _dotSize,
|
|
height: _dotSize,
|
|
margin: const EdgeInsets.symmetric(horizontal: _dotGap / 2),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: index == _index
|
|
? AppColors.brandRed
|
|
: AppColors.textPrimary.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Hero 中部居中文案与按钮组。
|
|
class _HeroCopy extends StatelessWidget {
|
|
const _HeroCopy({
|
|
super.key,
|
|
required this.drama,
|
|
required this.ctaHeight,
|
|
required this.onWatch,
|
|
});
|
|
|
|
final Drama drama;
|
|
final double ctaHeight;
|
|
final VoidCallback onWatch;
|
|
|
|
static const Color _titleCream = Color(0xFFE9D3B8);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final lang = Localizations.localeOf(context).languageCode;
|
|
final types = drama.localizedTypeLabels(lang);
|
|
final meta = [
|
|
if (types.isNotEmpty) types.first,
|
|
l10n.totalEpisodesShort(drama.episodeCount),
|
|
].join(' · ');
|
|
|
|
return Column(
|
|
key: const Key('home-hero-copy'),
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
drama.localizedTitle(lang),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: _titleCream,
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.15,
|
|
shadows: [Shadow(color: Color(0x80000000), blurRadius: 12)],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
meta,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: AppColors.textPrimary.withValues(alpha: 0.92),
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
drama.localizedSynopsis(lang),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: AppColors.textPrimary.withValues(alpha: 0.95),
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w400,
|
|
shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_WatchButton(
|
|
height: ctaHeight,
|
|
label: l10n.watchNowAction,
|
|
onTap: onWatch,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_FavoriteCircle(size: ctaHeight, drama: drama),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WatchButton extends StatelessWidget {
|
|
const _WatchButton({
|
|
required this.height,
|
|
required this.label,
|
|
required this.onTap,
|
|
});
|
|
|
|
final double height;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: AppColors.brandRed,
|
|
borderRadius: BorderRadius.circular(height / 2),
|
|
child: InkWell(
|
|
key: const Key('home-hero-watch'),
|
|
borderRadius: BorderRadius.circular(height / 2),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: height,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.play_arrow_rounded,
|
|
size: 20,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FavoriteCircle extends StatelessWidget {
|
|
const _FavoriteCircle({required this.size, required this.drama});
|
|
|
|
final double size;
|
|
final Drama drama;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final favorites = context.watch<FavoriteController>();
|
|
final saved = favorites.isSaved(drama.id);
|
|
return Material(
|
|
color: saved ? AppColors.brandRed : AppColors.transparent,
|
|
shape: CircleBorder(
|
|
side: BorderSide(
|
|
color: saved
|
|
? AppColors.brandRed
|
|
: AppColors.textPrimary.withValues(alpha: 0.85),
|
|
width: 1.5,
|
|
),
|
|
),
|
|
child: InkWell(
|
|
key: const Key('home-hero-favorite'),
|
|
customBorder: const CircleBorder(),
|
|
onTap: () => unawaited(favorites.toggle(drama.id, card: drama)),
|
|
child: SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: Icon(
|
|
saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
|
size: 20,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|