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

432 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/theme_provider.dart';
import '../providers/display_mode_provider.dart';
import '../utils/translations.dart'; // Translation helper
import 'camera_screen.dart';
import 'menu_creation_screen.dart';
import '../theme/app_colors.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 '../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 '../providers/ui_experiment_provider.dart'; // A/B Test
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../widgets/prefecture_filter_sheet.dart';
import '../widgets/pending_analysis_banner.dart'; // Phase 1: Banner widget
import '../widgets/common/error_retry_widget.dart';
// CR-006: NotifierProviderでオンボーディングチェック状態を管理グローバル変数を削除
class HasCheckedOnboardingNotifier extends Notifier<bool> {
@override
bool build() => false;
void setChecked() => state = true;
}
final hasCheckedOnboardingProvider = NotifierProvider<HasCheckedOnboardingNotifier, bool>(
HasCheckedOnboardingNotifier.new,
);
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayMode = ref.watch(displayModeProvider);
final sakeListAsync = ref.watch(sakeListProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
// CR-006: Onboarding Check (Run once per session) - NotifierProviderで管理
final hasChecked = ref.watch(hasCheckedOnboardingProvider);
if (!hasChecked) {
Future.microtask(() {
final profile = ref.read(userProfileProvider);
if (!profile.hasCompletedOnboarding && context.mounted) {
_showOnboardingDialog(context, ref);
}
ref.read(hasCheckedOnboardingProvider.notifier).setChecked();
});
}
// Filter States
final isSearching = ref.watch(sakeSearchQueryProvider).isNotEmpty;
final showFavorites = ref.watch(sakeFilterFavoriteProvider);
final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider);
final isMenuMode = ref.watch(menuModeProvider);
final userProfile = ref.watch(userProfileProvider);
final isBusinessMode = userProfile.isBusinessMode;
final t = Translations(userProfile.locale); // Translation helper
// Phase D6: フィルタリングされたリストでアイテム有無を判定
final hasItems = ref.watch(allSakeItemsProvider).asData?.value.isNotEmpty ?? false;
return Scaffold(
appBar: AppBar(
title: isMenuMode
? Text(t['menuCreation'], style: const TextStyle(fontWeight: FontWeight.bold))
: (isSearching
? Row(
children: [
Expanded(
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: t['searchPlaceholder'],
border: InputBorder.none,
hintStyle: const 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: t['sort'],
onPressed: () => _showSortMenu(context, ref, t),
),
],
)
: null),
actions: [
// Normal Actions
if (!isSearching) // Show Sort button here if not searching
IconButton(
icon: const Icon(LucideIcons.arrowUpDown),
tooltip: t['sort'],
onPressed: () => _showSortMenu(context, ref, t),
),
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: t['filterByPrefecture'],
color: selectedPrefecture != null ? appColors.brandPrimary : null,
),
IconButton(
icon: Icon(showFavorites ? Icons.favorite : Icons.favorite_border),
color: showFavorites ? Colors.pink : null,
onPressed: () => ref.read(sakeFilterFavoriteProvider.notifier).toggle(),
tooltip: t['favoritesOnly'],
),
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, t),
tooltip: t['helpGuide'],
),
],
),
body: SafeArea(
child: Column(
children: [
// Phase 1: 未解析Draft通知バナー
const PendingAnalysisBanner(),
if (!isMenuMode && hasItems)
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;
}
// Phase D6: フィルタリング後のリストで空状態判定
// Personal Modeではセット商品除外後の状態で判定
final filteredAsync = ref.watch(filteredByModeProvider);
final isListActuallyEmpty = filteredAsync.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),
Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(t['goBackToList'], textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)),
],
),
);
} else if (isListActuallyEmpty) {
return HomeEmptyState(isMenuMode: isMenuMode);
} 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) => ErrorRetryWidget(
message: '日本酒リストの読み込みに失敗しました',
details: e.toString(),
onRetry: () => ref.refresh(sakeListProvider),
),
),
),
],
),
),
floatingActionButton: isBusinessMode
? SpeedDial(
icon: LucideIcons.plus,
activeIcon: LucideIcons.x,
backgroundColor: appColors.brandPrimary,
foregroundColor: appColors.surfaceSubtle,
activeBackgroundColor: appColors.surfaceElevated,
shape: const CircleBorder(), // Fix white line artifact
overlayColor: Colors.black,
overlayOpacity: 0.5,
// A/B Test Animation
animationCurve: ref.watch(uiExperimentProvider).fabAnimation == 'bounce'
? Curves.elasticOut
: Curves.linear,
animationDuration: ref.watch(uiExperimentProvider).fabAnimation == 'bounce'
? const Duration(milliseconds: 400)
: const Duration(milliseconds: 250),
spacing: 12,
spaceBetweenChildren: 12,
children: [
SpeedDialChild(
child: const Text('🍶', style: TextStyle(fontSize: 24)),
backgroundColor: Colors.white,
label: t['createMenu'],
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MenuCreationScreen()),
);
},
),
SpeedDialChild(
child: Icon(LucideIcons.packagePlus, color: appColors.brandAccent),
backgroundColor: appColors.surfaceSubtle,
label: t['createSet'],
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () {
showDialog(
context: context,
builder: (context) => const AddSetItemDialog(),
);
},
),
SpeedDialChild(
child: Icon(LucideIcons.image, color: appColors.brandPrimary),
backgroundColor: appColors.surfaceSubtle,
label: t['selectFromGallery'],
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const CameraScreen(
mode: CameraMode.createItem,
),
),
);
},
),
SpeedDialChild(
child: Icon(LucideIcons.camera, color: appColors.brandPrimary),
backgroundColor: appColors.surfaceSubtle,
label: t['takePhoto'],
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
onTap: () {
// ✅ 遅延を削除してサクッと感を復元
// MaterialPageRouteのアニメーション中にカメラが初期化されるため、
// ユーザーは待たされている感じがしない
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CameraScreen()),
);
},
),
],
)
: FloatingActionButton(
onPressed: () {
// ✅ 遅延を削除してサクッと感を復元
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CameraScreen()),
);
},
backgroundColor: appColors.brandPrimary,
foregroundColor: appColors.surfaceSubtle,
child: const Icon(LucideIcons.camera),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
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, Translations t) {
final userProfile = ref.read(userProfileProvider);
final isBusinessMode = userProfile.isBusinessMode;
List<Map<String, dynamic>>? pages;
if (isBusinessMode) {
pages = [
{
'title': t['welcomeBusinessMode'],
'description': t['businessModeDesc'],
'icon': LucideIcons.store,
},
{
'title': t['setProductCreation'],
'description': t['setProductDesc'],
'icon': LucideIcons.packagePlus,
},
{
'title': t['instaPromo'],
'description': t['instaPromoDesc'],
'icon': LucideIcons.instagram,
},
{
'title': t['salesAnalytics'],
'description': t['salesAnalyticsDesc'],
'icon': LucideIcons.barChartBig,
},
];
}
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => OnboardingDialog(
pages: pages,
onFinish: () => Navigator.pop(ctx),
),
);
}
void _showSortMenu(BuildContext context, WidgetRef ref, Translations t) {
final currentSort = ref.read(sakeSortModeProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (dialogContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(t['sortTitle'], style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
),
ListTile(
leading: const Icon(LucideIcons.clock),
title: Text(t['sortNewest']),
trailing: currentSort == SortMode.newest ? Icon(LucideIcons.check, color: appColors.brandPrimary) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.newest);
Navigator.pop(dialogContext);
},
),
ListTile(
leading: const Icon(LucideIcons.history),
title: Text(t['sortOldest']),
trailing: currentSort == SortMode.oldest ? Icon(LucideIcons.check, color: appColors.brandPrimary) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.oldest);
Navigator.pop(dialogContext);
},
),
ListTile(
leading: const Icon(LucideIcons.arrowDownAZ),
title: Text(t['sortName']),
trailing: currentSort == SortMode.name ? Icon(LucideIcons.check, color: appColors.brandPrimary) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.name);
Navigator.pop(dialogContext);
},
),
ListTile(
leading: const Icon(LucideIcons.gripHorizontal),
title: Text(t['sortCustom']),
trailing: currentSort == SortMode.custom ? Icon(LucideIcons.check, color: appColors.brandPrimary) : null,
onTap: () {
ref.read(sakeSortModeProvider.notifier).set(SortMode.custom);
Navigator.pop(dialogContext);
},
),
const SizedBox(height: 16),
],
),
),
);
}
// Method _buildBusinessQuickFilters removed (Using SakeFilterChips instead)
} // End of HomeScreen class