import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; import '../models/sake_item.dart'; import '../screens/sake_detail_screen.dart'; /// 3D風カルーセルウィジェット /// 奥行き感のあるくるくるスクロールで日本酒を選択 class Sake3DCarousel extends StatefulWidget { final List items; final double height; const Sake3DCarousel({ super.key, required this.items, this.height = 200, }); @override State createState() => _Sake3DCarouselState(); } class _Sake3DCarouselState extends State { late PageController _pageController; double _currentPage = 0; @override void initState() { super.initState(); _pageController = PageController( viewportFraction: 0.35, // 隣のカードも見える initialPage: 0, ); _pageController.addListener(() { setState(() { _currentPage = _pageController.page ?? 0; }); }); } @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.items.isEmpty) { return SizedBox( height: widget.height, child: const Center( child: Text('関連する日本酒がありません'), ), ); } return SizedBox( height: widget.height, child: PageView.builder( controller: _pageController, itemCount: widget.items.length, itemBuilder: (context, index) { return _buildCarouselItem(widget.items[index], index); }, ), ); } Widget _buildCarouselItem(SakeItem item, int index) { // 現在のページからの距離を計算 final diff = (_currentPage - index).abs(); // 3D効果のための変換パラメータ final scale = max(0.8, 1 - (diff * 0.2)); // スケール: 0.8〜1.0 final opacity = max(0.4, 1 - (diff * 0.3)); // 透明度: 0.4〜1.0 final rotation = (diff * 0.1).clamp(-0.2, 0.2); // 回転: -0.2〜0.2 rad return Transform( transform: Matrix4.identity() ..setEntry(3, 2, 0.001) // 透視効果 ..scale(scale) ..rotateY(rotation), alignment: Alignment.center, child: Opacity( opacity: opacity, child: GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => SakeDetailScreen(sake: item), ), ); }, child: Card( elevation: 8, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, child: Stack( fit: StackFit.expand, children: [ // 背景画像 item.displayData.imagePaths.isNotEmpty ? Image.file( File(item.displayData.imagePaths.first), fit: BoxFit.cover, ) : Container( color: Colors.grey[300], child: const Icon(Icons.image, size: 50), ), // グラデーションオーバーレイ Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.7), ], ), ), ), // テキスト情報 Positioned( bottom: 16, left: 12, right: 12, child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( item.displayData.name, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold, shadows: [ Shadow( color: Colors.black, blurRadius: 4, ), ], ), ), const SizedBox(height: 4), Text( item.displayData.prefecture, style: TextStyle( color: Colors.white.withValues(alpha: 0.9), fontSize: 11, shadows: const [ Shadow( color: Colors.black, blurRadius: 4, ), ], ), ), ], ), ), // 中央のカードにインジケーター if (diff < 0.5) Positioned( top: 8, right: 8, child: Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: Theme.of(context).primaryColor, borderRadius: BorderRadius.circular(12), ), child: const Text( 'おすすめ', style: TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), ), ), ], ), ), ), ), ); } }