import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; const Duration _autoPlayInterval = Duration(milliseconds: 4500); class CoverItem { const CoverItem({ required this.title, required this.subtitle, required this.image, required this.onOpen, }); final String title; final String subtitle; final String image; final VoidCallback onOpen; } /// Full-bleed posters that turn as faces of a cube. Titles sit on the image. class CoverCarousel extends StatefulWidget { const CoverCarousel({super.key, required this.items, this.active = true}); final List items; final bool active; @override State createState() => _CoverCarouselState(); } class _CoverCarouselState extends State with SingleTickerProviderStateMixin { late final AnimationController _snap; double _page = 0; double _from = 0; double _to = 0; int? _pointer; double _drag = 0; double _dragPage = 0; Timer? _autoTimer; bool _tickerEnabled = true; @override void initState() { super.initState(); _snap = AnimationController(vsync: this)..addListener(_onSnap); } @override void dispose() { _autoTimer?.cancel(); _snap.dispose(); super.dispose(); } @override void didChangeDependencies() { super.didChangeDependencies(); final enabled = TickerMode.valuesOf(context).enabled; if (enabled != _tickerEnabled) { _tickerEnabled = enabled; _syncAutoTimer(); } else if (_autoTimer == null) { _syncAutoTimer(); } } @override void didUpdateWidget(covariant CoverCarousel oldWidget) { super.didUpdateWidget(oldWidget); if (_page > _last) _page = _last; if (oldWidget.items.length != widget.items.length || oldWidget.active != widget.active) { _syncAutoTimer(); } } double get _last => math.max(0, widget.items.length - 1).toDouble(); void _onSnap() { if (!mounted) return; final t = Curves.easeOutCubic.transform(_snap.value); setState(() => _page = _from + (_to - _from) * t); } void _turnTo(double target) { if (!mounted || widget.items.isEmpty) return; _from = _page; _to = target.clamp(0, _last); _snap.duration = const Duration(milliseconds: 420); _snap.forward(from: 0); } void _syncAutoTimer() { _autoTimer?.cancel(); _autoTimer = null; if (!mounted || !widget.active || !_tickerEnabled || widget.items.length <= 1) { return; } _autoTimer = Timer.periodic(_autoPlayInterval, (_) { if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) { return; } final current = _page.round().clamp(0, _last.toInt()); if (current >= _last.toInt()) { setState(() => _page = 0); } else { _turnTo((current + 1).toDouble()); } }); } @override Widget build(BuildContext context) { if (widget.items.isEmpty) return const SizedBox.shrink(); final current = _page.round().clamp(0, widget.items.length - 1); final l10n = AppLocalizations.of(context)!; return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final height = math.min(width * 0.52, 210).toDouble(); return Semantics( container: true, label: l10n.carouselSemantics(current + 1, widget.items.length), child: SizedBox( width: double.infinity, height: height, child: Stack( children: [ Positioned.fill(child: _stage(width)), _indicator(l10n, current), ], ), ), ); }, ); } Widget _stage(double width) { return Listener( behavior: HitTestBehavior.opaque, onPointerDown: (event) { _autoTimer?.cancel(); _autoTimer = null; _snap.stop(); _pointer = event.pointer; _drag = 0; _dragPage = _page; }, onPointerMove: (event) { if (event.pointer != _pointer) return; _drag += event.delta.dx; setState(() { _page = (_dragPage - _drag / width).clamp(0.0, _last); }); }, onPointerUp: (event) { if (event.pointer != _pointer) return; _pointer = null; _turnTo(_page.roundToDouble()); _syncAutoTimer(); }, onPointerCancel: (event) { if (event.pointer != _pointer) return; _pointer = null; _turnTo(_page.roundToDouble()); _syncAutoTimer(); }, child: ClipRect( child: Stack( fit: StackFit.expand, children: [for (final index in _visible) _face(index, width)], ), ), ); } Widget _indicator(AppLocalizations l10n, int current) { return Positioned( left: 0, right: 0, bottom: 8, child: Semantics( label: l10n.carouselSemantics(current + 1, widget.items.length), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var index = 0; index < widget.items.length; index++) Container( key: Key('carousel-dot-$index'), width: 6, height: 6, margin: const EdgeInsets.symmetric(horizontal: 3), decoration: BoxDecoration( shape: BoxShape.circle, color: index == current ? AppColors.onPrimary : AppColors.muted.withValues(alpha: 0.4), ), ), ], ), ), ); } List get _visible { final first = _page.floor(); final indexes = [first, first + 1]; indexes.retainWhere((index) => index >= 0 && index < widget.items.length); indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs())); return indexes; } Widget _face(int index, double width) { final delta = index - _page; if (delta <= -1 || delta >= 1) return const SizedBox.shrink(); final item = widget.items[index]; final angle = delta * math.pi / 2; // Push the face out to the cube surface, spin the whole cube on its // vertical center, then bring that center back to the banner. final face = Matrix4.identity() ..translateByDouble(0, 0, -width / 2, 1) ..rotateY(angle) ..translateByDouble(0, 0, width / 2, 1); final transform = Matrix4.identity() ..setEntry(3, 2, 0.0018) ..multiply(face); return Positioned.fill( child: Transform( alignment: Alignment.center, filterQuality: FilterQuality.medium, transform: transform, child: GestureDetector( onTap: () { if ((_page - index).abs() < 0.08) item.onOpen(); }, child: _poster(item), ), ), ); } Widget _poster(CoverItem item) { return Stack( fit: StackFit.expand, children: [ RemoteImage( item.image, showFallbackIcon: true, fallbackIconKey: const Key('carousel-image-fallback'), ), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)], stops: [0, 0.5, 1], ), ), ), Positioned( left: 20, right: 20, bottom: 30, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600, height: 1.15, shadows: [Shadow(blurRadius: 12, color: Colors.black)], ), ), const SizedBox(height: 6), Text( item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Color(0xF2FFFFFF), fontSize: 14, fontWeight: FontWeight.w400, height: 1.2, shadows: [Shadow(blurRadius: 8, color: Colors.black)], ), ), ], ), ), ], ); } }