Files
ReelShort/lib/pages/discover/widgets/feed_banner_carousel.dart
T

306 lines
9.0 KiB
Dart

// 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<FeedBanner> banners;
final ValueChanged<FeedBanner> onOpen;
final bool active;
final Drama? Function(FeedBanner banner)? dramaFor;
/// 首页 hero 铺满屏幕并去掉圆角;发现页保持默认 false。
final bool flush;
@override
State<FeedBannerCarousel> createState() => _FeedBannerCarouselState();
}
class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
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),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: drama == null
? const [AppColors.transparent, AppColors.bannerScrimMid]
: const [AppColors.transparent, AppColors.bannerScrimEnd],
),
),
),
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: AppColors.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,
),
);
}