216 lines
9.4 KiB
Dart
216 lines
9.4 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'dart:io';
|
||
import '../../models/sake_item.dart';
|
||
import '../../providers/menu_providers.dart';
|
||
import '../../screens/sake_detail_screen.dart';
|
||
import '../../theme/app_theme.dart';
|
||
import '../../theme/app_colors.dart';
|
||
import 'package:lucide_icons/lucide_icons.dart';
|
||
// Haptic via InkWell? No, explicit HapticFeedback used generally.
|
||
|
||
class SakeListItem extends ConsumerWidget {
|
||
final SakeItem sake;
|
||
final bool isMenuMode;
|
||
final int? index; // For ReorderableDragStartListener
|
||
|
||
const SakeListItem({
|
||
super.key,
|
||
required this.sake,
|
||
required this.isMenuMode,
|
||
this.index,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final isSelected = ref.watch(selectedMenuSakeIdsProvider).contains(sake.id);
|
||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||
|
||
// Adaptive selection color
|
||
final selectedColor = appColors.brandAccent.withValues(alpha: 0.15);
|
||
|
||
return Card(
|
||
clipBehavior: Clip.antiAlias,
|
||
elevation: 1, // Slight elevation
|
||
color: isMenuMode && isSelected ? selectedColor : null,
|
||
shape: isMenuMode && isSelected
|
||
? RoundedRectangleBorder(side: BorderSide(color: appColors.brandAccent, width: 2), borderRadius: BorderRadius.circular(6))
|
||
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), // Reverted to 6px as requested
|
||
child: InkWell(
|
||
onTap: () {
|
||
if (isMenuMode) {
|
||
ref.read(selectedMenuSakeIdsProvider.notifier).toggle(sake.id);
|
||
return;
|
||
}
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => SakeDetailScreen(sake: sake),
|
||
),
|
||
);
|
||
},
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Selection Checkbox for ListView
|
||
if (isMenuMode)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: AppTheme.spacingXLarge, horizontal: AppTheme.spacingMedium),
|
||
child: Icon(
|
||
isSelected ? Icons.check_circle : Icons.check_circle_outline,
|
||
color: isSelected ? appColors.brandPrimary : appColors.iconSubtle,
|
||
size: 28,
|
||
),
|
||
),
|
||
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: SizedBox(
|
||
width: 80, // 100 → 80(縦長に見えるように横幅を縮小)
|
||
height: 120, // 100 → 120(縦長のアスペクト比: 2:3)
|
||
child: Hero(
|
||
tag: sake.id,
|
||
child: sake.displayData.imagePaths.isNotEmpty
|
||
? Image.file(
|
||
File(sake.displayData.imagePaths.first),
|
||
fit: BoxFit.cover, // 縦長の瓶がつぶれずに表示される
|
||
cacheWidth: 160, // 80 x 2 (高解像度対応)
|
||
cacheHeight: 240, // 120 x 2
|
||
// 段階的に画像を表示(体感速度向上)
|
||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||
if (wasSynchronouslyLoaded) return child;
|
||
return AnimatedOpacity(
|
||
opacity: frame == null ? 0 : 1,
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
child: child,
|
||
);
|
||
},
|
||
errorBuilder: (context, error, stackTrace) {
|
||
return Container(
|
||
color: appColors.surfaceSubtle,
|
||
child: Center(
|
||
child: Icon(
|
||
LucideIcons.imageOff,
|
||
color: appColors.iconSubtle,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
)
|
||
: (sake.itemType == ItemType.set
|
||
? Image.asset(
|
||
'assets/images/set_placeholder.png',
|
||
fit: BoxFit.cover,
|
||
)
|
||
: Container(
|
||
color: appColors.surfaceSubtle,
|
||
child: Center(
|
||
child: Icon(
|
||
LucideIcons.image,
|
||
size: 40,
|
||
color: appColors.iconSubtle,
|
||
),
|
||
),
|
||
)),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppTheme.spacingMedium),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
// セット商品バッジ
|
||
if (sake.itemType == ItemType.set)
|
||
Container(
|
||
margin: const EdgeInsets.only(right: 6),
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: appColors.brandAccent,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
'セット',
|
||
style: TextStyle(
|
||
color: appColors.surfaceElevated,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
sake.displayData.displayName,
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (sake.userData.isFavorite && !isMenuMode)
|
||
const Icon(Icons.favorite, color: Colors.pink, size: 16),
|
||
],
|
||
),
|
||
const SizedBox(height: AppTheme.spacingTiny),
|
||
// Brand / Prefecture (セット商品では非表示)
|
||
if (sake.itemType != ItemType.set &&
|
||
(sake.displayData.displayBrewery.isNotEmpty || sake.displayData.displayPrefecture.isNotEmpty))
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'${sake.displayData.displayBrewery} / ${sake.displayData.displayPrefecture}',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
// セット商品の説明文表示
|
||
if (sake.itemType == ItemType.set && sake.displayData.catchCopy != null)
|
||
Text(
|
||
sake.displayData.catchCopy!,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (!isMenuMode && sake.hiddenSpecs.flavorTags.isNotEmpty) ...[
|
||
const SizedBox(height: AppTheme.spacingSmall),
|
||
Wrap(
|
||
spacing: 4,
|
||
runSpacing: 4,
|
||
children: sake.hiddenSpecs.flavorTags.take(3).map((tag) => Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: appColors.surfaceSubtle,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
tag,
|
||
style: TextStyle(fontSize: 10, color: appColors.textSecondary),
|
||
),
|
||
)).toList(),
|
||
)
|
||
]
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|