// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/models/home_feed.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 _carouselHeight = 154; const double _indicatorHeight = 24; const double _dotSize = 6; const double _activeDotWidth = 18; const double _viewportFraction = 0.94; const Duration _autoAdvanceInterval = Duration(milliseconds: 4500); const Duration _pageTransition = Duration(milliseconds: 520); /// Auto-advancing, touch-pausable carousel for real `/app/home/feed` banners. class FeedBannerCarousel extends StatefulWidget { const FeedBannerCarousel({ super.key, required this.banners, required this.onOpen, this.active = true, this.dramaFor, this.flush = false, }); final List banners; final ValueChanged onOpen; final bool active; final Drama? Function(FeedBanner banner)? dramaFor; /// 首页 hero 铺满屏幕并去掉圆角;发现页保持默认 false。 final bool flush; @override State createState() => _FeedBannerCarouselState(); } class _FeedBannerCarouselState extends State { late final PageController _controller; Timer? _timer; int _index = 0; bool _pressed = false; bool _tickerEnabled = true; @override void initState() { super.initState(); _controller = PageController( viewportFraction: widget.dramaFor == null ? _viewportFraction : 1, ); 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 FeedBannerCarousel oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.banners.length != widget.banners.length || oldWidget.active != widget.active) { if (_index >= widget.banners.length) _index = 0; _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(_autoAdvanceInterval, (_) => _advance()); } void _advance() { if (!_controller.hasClients || widget.banners.length < 2) return; final next = (_index + 1) % widget.banners.length; _controller.animateToPage( next, duration: _pageTransition, curve: Curves.easeOutCubic, ); } void _pause() { _pressed = true; _timer?.cancel(); } void _resume() { _pressed = false; _syncTimer(); } @override Widget build(BuildContext context) { if (widget.banners.isEmpty) return const SizedBox.shrink(); final hero = widget.dramaFor != null; final height = hero ? AppDimens.heroBannerHeight : _carouselHeight; final pages = PageView.builder( controller: _controller, itemCount: widget.banners.length, onPageChanged: (value) => setState(() => _index = value), itemBuilder: (context, index) => _bannerCard(index), ); return Listener( key: const Key('feed-banner-carousel'), onPointerDown: (_) => _pause(), onPointerUp: (_) => _resume(), onPointerCancel: (_) => _resume(), child: hero ? SizedBox( height: height, child: Stack( children: [ Positioned.fill(child: pages), Positioned( left: 0, right: 0, bottom: AppDimens.spaceXs, child: SizedBox( height: _indicatorHeight, child: _indicator(context), ), ), ], ), ) : Column( children: [ SizedBox(height: height, child: pages), SizedBox(height: _indicatorHeight, child: _indicator(context)), ], ), ); } Widget _bannerCard(int index) { final banner = widget.banners[index]; final dramaId = banner.dramaId; final drama = widget.dramaFor?.call(banner); final radius = widget.flush ? 0.0 : (drama == null ? AppDimens.authCardRadius : AppDimens.heroBannerRadius); final borderRadius = BorderRadius.circular(radius); final card = ClipRRect( borderRadius: borderRadius, child: Stack( fit: StackFit.expand, children: [ RemoteImage(banner.imageUrl, showFallbackIcon: true), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: drama == null ? const [Color(0x00000000), Color(0x8A0B0C10)] : const [Color(0x00000000), Color(0xE6000000)], ), ), ), if (drama != null) Positioned( left: AppDimens.pageHorizontal, right: _copyRightGap, bottom: AppDimens.spaceXl + AppDimens.spaceSm, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( drama.title, key: Key('home-hero-title-$index'), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: AppDimens.heroTitle, fontWeight: FontWeight.w800, ), ), const SizedBox(height: AppDimens.spaceXs), Text( drama.synopsis, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: AppDimens.heroSubtitle, ), ), ], ), ), if (dramaId != null && drama == null) const Positioned(right: 14, bottom: 14, child: _PlayMark()), ], ), ); final page = Padding( padding: EdgeInsets.symmetric(horizontal: drama == null ? 5 : 0), child: dramaId == null ? card : Material( color: Colors.transparent, borderRadius: borderRadius, clipBehavior: Clip.antiAlias, child: InkWell( key: Key('feed-banner-$index'), onTap: () => widget.onOpen(banner), child: card, ), ), ); return Semantics(container: true, button: dramaId != null, child: page); } Widget _indicator(BuildContext context) { final l10n = AppLocalizations.of(context)!; return ExcludeSemantics( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var index = 0; index < widget.banners.length; index++) Semantics( label: l10n.carouselSemantics(index + 1, widget.banners.length), selected: index == _index, child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOut, key: Key('feed-banner-dot-$index'), width: index == _index ? _activeDotWidth : _dotSize, height: _dotSize, margin: const EdgeInsets.symmetric(horizontal: 3), decoration: BoxDecoration( color: index == _index ? AppColors.onPrimary : AppColors.muted, borderRadius: BorderRadius.circular(_dotSize), ), ), ), ], ), ); } } const double _copyRightGap = 64; class _PlayMark extends StatelessWidget { const _PlayMark(); @override Widget build(BuildContext context) => Container( width: 38, height: 38, decoration: BoxDecoration( color: AppColors.blue, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: AppColors.blue.withValues(alpha: 0.35), blurRadius: 18, offset: const Offset(0, 6), ), ], ), child: const Icon( Icons.play_arrow_rounded, color: AppColors.onPrimary, size: 22, ), ); }