302 lines
8.9 KiB
Dart
302 lines
8.9 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,
|
|
});
|
|
|
|
final List<FeedBanner> banners;
|
|
final ValueChanged<FeedBanner> onOpen;
|
|
final bool active;
|
|
final Drama? Function(FeedBanner banner)? dramaFor;
|
|
|
|
@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 card = ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
drama == null ? AppDimens.authCardRadius : AppDimens.heroBannerRadius,
|
|
),
|
|
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.circular(
|
|
drama == null
|
|
? AppDimens.authCardRadius
|
|
: AppDimens.heroBannerRadius,
|
|
),
|
|
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,
|
|
),
|
|
);
|
|
}
|