ponshu-room-lite/lib/widgets/sake_3d_carousel.dart

206 lines
6.3 KiB
Dart

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<SakeItem> items;
final double height;
const Sake3DCarousel({
super.key,
required this.items,
this.height = 200,
});
@override
State<Sake3DCarousel> createState() => _Sake3DCarouselState();
}
class _Sake3DCarouselState extends State<Sake3DCarousel> {
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,
),
),
),
),
],
),
),
),
),
);
}
}