import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/widgets/remote_image.dart'; 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}); final List items; @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; @override void initState() { super.initState(); _snap = AnimationController(vsync: this)..addListener(_onSnap); } @override void dispose() { _snap.dispose(); super.dispose(); } double get _last => math.max(0, widget.items.length - 1).toDouble(); void _onSnap() { final t = Curves.easeOutCubic.transform(_snap.value); setState(() => _page = _from + (_to - _from) * t); } void _turnTo(double target) { _from = _page; _to = target.clamp(0, _last); _snap.duration = const Duration(milliseconds: 420); _snap.forward(from: 0); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final height = math.min(width * 0.52, 210).toDouble(); return SizedBox( width: double.infinity, height: height, child: _stage(width), ); }, ); } Widget _stage(double width) { return Listener( behavior: HitTestBehavior.opaque, onPointerDown: (event) { _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()); }, onPointerCancel: (event) { if (event.pointer != _pointer) return; _pointer = null; _turnTo(_page.roundToDouble()); }, child: ClipRect( child: Stack( fit: StackFit.expand, children: [for (final index in _visible) _face(index, width)], ), ), ); } 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), 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: 22, 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)], ), ), ], ), ), ], ); } }