ponshu-room-lite/lib/screens/home_screen.dart

501 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/theme_provider.dart';
import '../providers/display_mode_provider.dart';
import 'camera_screen.dart';
import 'menu_creation_screen.dart';
import '../theme/app_theme.dart';
import '../providers/sake_list_provider.dart';
import '../providers/filter_providers.dart';
import '../providers/menu_providers.dart'; // Phase 2-1
import '../models/sake_item.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/services.dart'; // Haptic
import '../services/gemini_service.dart';
import '../services/image_compression_service.dart';
import '../widgets/analyzing_dialog.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' show join;
import '../widgets/sake_search_delegate.dart';
import '../widgets/onboarding_dialog.dart';
import '../widgets/home/sake_filter_chips.dart';
import '../widgets/home/home_empty_state.dart';
import '../widgets/home/sake_no_match_state.dart';
import '../widgets/home/sake_list_view.dart';
import '../widgets/home/sake_grid_view.dart';
import '../widgets/add_set_item_dialog.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../widgets/prefecture_filter_sheet.dart';
// Use a simple global variable for session check instead of StateProvider to avoid dependency issues
bool _hasCheckedOnboarding = false;
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fontPref = ref.watch(fontPreferenceProvider);
final displayMode = ref.watch(displayModeProvider);
final sakeListAsync = ref.watch(sakeListProvider);
// Onboarding Check (Run once per session)
if (!_hasCheckedOnboarding) {
Future.microtask(() {
final profile = ref.read(userProfileProvider);
if (!profile.hasCompletedOnboarding) {
_showOnboardingDialog(context, ref);
}
_hasCheckedOnboarding = true;
});
}
// Filter States
final isSearching = ref.watch(sakeSearchQueryProvider).isNotEmpty;
final showFavorites = ref.watch(sakeFilterFavoriteProvider);
final selectedTag = ref.watch(sakeFilterTagProvider);
final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider);
final isMenuMode = ref.watch(menuModeProvider);
final userProfile = ref.watch(userProfileProvider);
final isBusinessMode = userProfile.isBusinessMode;
return Scaffold(
appBar: AppBar(
title: isMenuMode
? const Text('お品書き作成', style: TextStyle(fontWeight: FontWeight.bold))
: (isSearching
? Row(
children: [
Expanded(
child: TextField(
autofocus: true,
decoration: const InputDecoration(
hintText: '銘柄・酒蔵・都道府県...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.white70),
),
style: const TextStyle(color: Colors.white),
onChanged: (value) => ref.read(sakeSearchQueryProvider.notifier).set(value),
),
),
// Sort Button (Searching State)
IconButton(
icon: const Icon(LucideIcons.arrowUpDown),
tooltip: '並び替え',
onPressed: () => _showSortMenu(context, ref),
),
],
)
: null),
actions: [
if (false) ...[
// Menu Mode Actions
] else ...[
// Normal Actions
if (isBusinessMode)
IconButton(
icon: const Icon(LucideIcons.plus),
tooltip: 'セット商品を追加',
onPressed: () {
showDialog(
context: context,
builder: (context) => const AddSetItemDialog(),
);
},
),
if (!isSearching) // Show Sort button here if not searching
IconButton(
icon: const Icon(LucideIcons.arrowUpDown),
tooltip: '並び替え',
onPressed: () => _showSortMenu(context, ref),
),
IconButton(
icon: const Icon(LucideIcons.search),
onPressed: () => showSearch(context: context, delegate: SakeSearchDelegate(ref)),
),
// ... rest of icons (Location, Favorite, DisplayMode, Guide)
IconButton(
icon: const Icon(LucideIcons.mapPin),
onPressed: () => PrefectureFilterSheet.show(context),
tooltip: '都道府県で絞り込み',
color: selectedPrefecture != null ? AppTheme.posimaiBlue : null,
),
IconButton(
icon: Icon(showFavorites ? Icons.favorite : Icons.favorite_border),
color: showFavorites ? Colors.pink : null,
onPressed: () => ref.read(sakeFilterFavoriteProvider.notifier).toggle(),
tooltip: 'Favorites Only',
),
IconButton(
icon: Icon(displayMode == 'list' ? LucideIcons.layoutGrid : LucideIcons.list),
onPressed: () => ref.read(displayModeProvider.notifier).toggle(),
),
IconButton(
icon: const Icon(LucideIcons.helpCircle),
onPressed: () => _showUsageGuide(context, ref),
tooltip: 'ヘルプ・ガイド',
),
],
],
),
body: SafeArea(
child: Column(
children: [
if (!isMenuMode)
if (!isMenuMode)
SakeFilterChips(
mode: isBusinessMode ? FilterChipMode.business : FilterChipMode.personal
),
// Menu Info Banner Removed
Expanded(
child: sakeListAsync.when(
data: (sakeList) {
final showSelected = isMenuMode && ref.watch(menuShowSelectedOnlyProvider);
List<SakeItem> displayList;
if (showSelected) {
final orderedIds = ref.watch(menuOrderedIdsProvider);
// Map Ordered Ids to Objects.
// Note: O(N*M) if naive. Use Map for O(N).
final sakeMap = {for (var s in sakeList) s.id: s};
displayList = orderedIds
.map((id) => sakeMap[id])
.where((s) => s != null)
.cast<SakeItem>()
.toList();
} else {
displayList = sakeList;
}
// Check if Global List is empty vs Filtered List is empty
final isListActuallyEmpty = ref.watch(rawSakeListItemsProvider).asData?.value.isEmpty ?? true;
if (displayList.isEmpty) {
if (showSelected) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('お品書きに追加されたお酒はありません', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('リスト画面に戻って、掲載したいお酒の\nチェックボックスを選択してください', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)),
],
),
);
} else if (isListActuallyEmpty) {
return const HomeEmptyState();
} else {
return const SakeNoMatchState();
}
}
// Logic: Reorder only if Custom Sort is active (and not searching)
final sortMode = ref.watch(sakeSortModeProvider);
final isCustomSort = sortMode == SortMode.custom;
final canReorder = isCustomSort && !isSearching; // Menu mode doesn't support reorder
return displayMode == 'list'
? SakeListView(sakeList: displayList, isMenuMode: false, enableReorder: canReorder)
: SakeGridView(sakeList: displayList, isMenuMode: false, enableReorder: canReorder);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
),
),
],
),
),
floatingActionButton: isBusinessMode
? SpeedDial(
icon: LucideIcons.plus,
activeIcon: LucideIcons.x,
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
activeBackgroundColor: Colors.grey[800],
overlayColor: Colors.black,
overlayOpacity: 0.5,
spacing: 12,
spaceBetweenChildren: 12,
children: [
SpeedDialChild(
child: const Text('🍶', style: TextStyle(fontSize: 24)),
backgroundColor: Colors.white,
label: 'お品書き作成',
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MenuCreationScreen()),
);
},
),
SpeedDialChild(
child: const Icon(LucideIcons.packagePlus, color: Colors.orange),
backgroundColor: Colors.white,
label: 'セット商品を追加',
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () {
showDialog(
context: context,
builder: (context) => const AddSetItemDialog(),
);
},
),
SpeedDialChild(
child: const Icon(LucideIcons.image, color: AppTheme.posimaiBlue),
backgroundColor: Colors.white,
label: '画像の読み込み',
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () async {
HapticFeedback.heavyImpact();
await _pickFromGallery(context);
},
),
SpeedDialChild(
child: const Icon(LucideIcons.camera, color: AppTheme.posimaiBlue),
backgroundColor: Colors.white,
label: '商品を撮影',
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const CameraScreen()),
);
},
),
],
)
: GestureDetector(
onLongPress: () async {
HapticFeedback.heavyImpact();
await _pickFromGallery(context);
},
child: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const CameraScreen()),
);
},
backgroundColor: AppTheme.posimaiBlue,
foregroundColor: Colors.white,
child: const Icon(LucideIcons.camera),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
Future<void> _pickFromGallery(BuildContext context) async {
final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null && context.mounted) {
_processImage(context, image.path);
}
}
Future<void> _processImage(BuildContext context, String sourcePath) async {
try {
// Show AnalyzingDialog immediately
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
);
// Compress and copy image to app docs
final directory = await getApplicationDocumentsDirectory();
final String targetPath = join(directory.path, '${const Uuid().v4()}.jpg');
final String imagePath = await ImageCompressionService.compressForGemini(sourcePath, targetPath: targetPath);
// Gemini Analysis
final geminiService = GeminiService();
final result = await geminiService.analyzeSakeLabel([imagePath]);
// Create SakeItem
// Create SakeItem (Schema v2.0)
final sakeItem = SakeItem(
id: const Uuid().v4(),
displayData: DisplayData(
name: result.name ?? '不明な日本酒',
brewery: result.brand ?? '不明',
prefecture: result.prefecture ?? '不明',
catchCopy: result.catchCopy,
imagePaths: [imagePath],
rating: null,
),
hiddenSpecs: HiddenSpecs(
description: result.description,
tasteStats: result.tasteStats,
flavorTags: result.flavorTags,
),
metadata: Metadata(
createdAt: DateTime.now(),
aiConfidence: result.confidenceScore,
),
);
// Save to Hive
final box = Hive.box<SakeItem>('sake_items');
await box.add(sakeItem);
// Prepend new item to sort order so it appears at the top
final settingsBox = Hive.box('settings');
final List<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?)
?.cast<String>() ?? [];
currentOrder.insert(0, sakeItem.id); // Insert at beginning
await settingsBox.put('sake_sort_order', currentOrder);
if (!context.mounted) return;
// Close Dialog
Navigator.of(context).pop();
// Success Message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${sakeItem.displayData.name} を登録しました!'),
duration: const Duration(seconds: 2),
),
);
} catch (e) {
if (context.mounted) {
// Attempt to pop dialog if it's open (this is heuristic, better state mgmt would be ideal)
// But for now, we assume top is dialog.
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('解析エラー: $e')),
);
}
}
}
void _showOnboardingDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => OnboardingDialog(
onFinish: () {
Navigator.pop(ctx);
ref.read(userProfileProvider.notifier).completeOnboarding();
},
),
);
}
void _showUsageGuide(BuildContext context, WidgetRef ref) {
final userProfile = ref.read(userProfileProvider);
final isBusinessMode = userProfile.isBusinessMode;
List<Map<String, String>>? pages;
if (isBusinessMode) {
pages = [
{
'title': 'ビジネスモードへようこそ',
'description': '飲食店様向けの機能を集約しました。\n在庫管理からメニュー作成まで、\nプロの仕事を強力にサポートします。',
'icon': '💼',
},
{
'title': 'セット商品の作成',
'description': '飲み比べセットやコース料理など、\n複数のお酒をまとめた「セット商品」を\n簡単に作成・管理できます。',
'icon': '🍱',
},
{
'title': '販促ツール(インスタ)',
'description': '本日のおすすめをSNSですぐに発信。\nInstaタブから、美しい画像を\nワンタップで生成できます。',
'icon': '📸',
},
{
'title': '高度な分析',
'description': '売れ筋や味の傾向を分析。\nお客様に喜ばれるラインナップ作りを\nデータで支援します。',
'icon': '📊',
},
];
}
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => OnboardingDialog(
pages: pages,
onFinish: () => Navigator.pop(ctx),
),
);
}
void _showSortMenu(BuildContext context, WidgetRef ref) {
final currentSort = ref.read(sakeSortModeProvider);
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text('並び替え', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
),
ListTile(
leading: const Icon(LucideIcons.clock),
title: const Text('新しい順(登録日)'),
trailing: currentSort == SortMode.newest ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.newest);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(LucideIcons.history),
title: const Text('古い順(登録日)'),
trailing: currentSort == SortMode.oldest ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.oldest);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(LucideIcons.arrowDownAZ),
title: const Text('名前順(あいうえお)'),
trailing: currentSort == SortMode.name ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.name);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(LucideIcons.gripHorizontal),
title: const Text('カスタム(ドラッグ配置)'),
trailing: currentSort == SortMode.custom ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.custom);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
// Method _buildBusinessQuickFilters removed (Using SakeFilterChips instead)
} // End of HomeScreen class