From ae49b10407ed376eab112fbe4bddd4191f0e587f Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 17:53:54 +0800 Subject: [PATCH] =?UTF-8?q?chore(client):=20=E5=BD=92=E6=A1=A3=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E9=A1=B5=20widgets=EF=BC=88=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E6=96=B0=E5=A2=9E=E7=9B=AE=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discover/widgets/cover_carousel.dart | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 lib/pages/discover/widgets/cover_carousel.dart diff --git a/lib/pages/discover/widgets/cover_carousel.dart b/lib/pages/discover/widgets/cover_carousel.dart new file mode 100644 index 0000000..cd53152 --- /dev/null +++ b/lib/pages/discover/widgets/cover_carousel.dart @@ -0,0 +1,206 @@ +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)], + ), + ), + ], + ), + ), + ], + ); + } +}