235 lines
6.5 KiB
Dart
235 lines
6.5 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.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,
|
|
});
|
|
|
|
final List<FeedBanner> banners;
|
|
final ValueChanged<FeedBanner> onOpen;
|
|
final bool active;
|
|
|
|
@override
|
|
State<FeedBannerCarousel> createState() => _FeedBannerCarouselState();
|
|
}
|
|
|
|
class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
|
|
final PageController _controller = PageController(
|
|
viewportFraction: _viewportFraction,
|
|
);
|
|
Timer? _timer;
|
|
int _index = 0;
|
|
bool _pressed = false;
|
|
bool _tickerEnabled = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
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();
|
|
return Listener(
|
|
key: const Key('feed-banner-carousel'),
|
|
onPointerDown: (_) => _pause(),
|
|
onPointerUp: (_) => _resume(),
|
|
onPointerCancel: (_) => _resume(),
|
|
child: Column(
|
|
children: [
|
|
SizedBox(
|
|
height: _carouselHeight,
|
|
child: PageView.builder(
|
|
controller: _controller,
|
|
itemCount: widget.banners.length,
|
|
onPageChanged: (value) => setState(() => _index = value),
|
|
itemBuilder: (context, index) => _bannerCard(index),
|
|
),
|
|
),
|
|
SizedBox(height: _indicatorHeight, child: _indicator(context)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _bannerCard(int index) {
|
|
final banner = widget.banners[index];
|
|
final dramaId = banner.dramaId;
|
|
final card = ClipRRect(
|
|
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
RemoteImage(banner.imageUrl, showFallbackIcon: true),
|
|
const DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Color(0x00000000), Color(0x8A0B0C10)],
|
|
),
|
|
),
|
|
),
|
|
if (dramaId != null)
|
|
const Positioned(right: 14, bottom: 14, child: _PlayMark()),
|
|
],
|
|
),
|
|
);
|
|
final page = Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 5),
|
|
child: dramaId == null
|
|
? card
|
|
: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|