diff --git a/lib/screens/sake_detail/sections/sake_pricing_section.dart b/lib/screens/sake_detail/sections/sake_pricing_section.dart new file mode 100644 index 0000000..58e953e --- /dev/null +++ b/lib/screens/sake_detail/sections/sake_pricing_section.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../models/sake_item.dart'; +import '../../../models/user_profile.dart'; +import '../../../providers/theme_provider.dart'; +import '../../../services/pricing_calculator.dart'; +import '../../../theme/app_colors.dart'; + +/// Business pricing section for sake detail screen. +/// Displays pricing info and provides price editing dialog. +class SakePricingSection extends ConsumerWidget { + final SakeItem sake; + final ValueChanged onUpdated; + + const SakePricingSection({ + super.key, + required this.sake, + required this.onUpdated, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userProfile = ref.watch(userProfileProvider); + if (!userProfile.isBusinessMode) return const SizedBox.shrink(); + + return _buildPricingContent(context, userProfile); + } + + Widget _buildPricingContent(BuildContext context, UserProfile userProfile) { + final appColors = Theme.of(context).extension()!; + final calculatedPrice = PricingCalculator.calculatePrice(sake); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Theme.of(context).primaryColor.withValues(alpha: 0.2)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.coins, color: appColors.brandPrimary, size: 18), + const SizedBox(width: 6), + Text( + '価格設定', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: appColors.brandPrimary), + ), + ], + ), + const SizedBox(height: 4), + Text( + calculatedPrice > 0 + ? '現在$calculatedPrice円' + : '未設定', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: calculatedPrice > 0 + ? appColors.brandPrimary + : appColors.textTertiary, + ), + ), + ], + ), + ElevatedButton( + onPressed: () => _showPriceSettingsDialog(context, userProfile), + style: ElevatedButton.styleFrom( + backgroundColor: appColors.brandPrimary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + child: const Text('編集'), + ), + ], + ), + ); + } + + Future _showPriceSettingsDialog(BuildContext context, UserProfile argProfile) async { + if (!argProfile.isBusinessMode) return; + + int? cost = sake.userData.costPrice; + int? manual = sake.userData.price; + double markup = sake.userData.markup; + Map variants = Map.from(sake.userData.priceVariants ?? {}); + + String tempName = ''; + String tempPrice = ''; + final TextEditingController nameController = TextEditingController(); + final TextEditingController priceController = TextEditingController(); + + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setModalState) { + void addVariant() { + if (tempName.isNotEmpty && tempPrice.isNotEmpty) { + final parsedPrice = int.tryParse(tempPrice); + if (parsedPrice != null) { + setModalState(() { + variants[tempName] = parsedPrice; + tempName = ''; + tempPrice = ''; + nameController.clear(); + priceController.clear(); + }); + } + } + } + + return AlertDialog( + title: const Text('価格設定', style: TextStyle(fontWeight: FontWeight.bold)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + initialValue: manual?.toString() ?? '', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '販売価格 (税込)', + hintText: '手動で設定する場合に入力', + suffixText: '円', + border: OutlineInputBorder(), + ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + onChanged: (v) => setModalState(() => manual = int.tryParse(v)), + ), + const SizedBox(height: 24), + + const Text('提供サイズ選択', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: [ + for (var preset in ['グラス (90ml)', '一合 (180ml)', 'ボトル (720ml)']) + ChoiceChip( + label: Text(preset), + selected: tempName == preset, + onSelected: (selected) { + setModalState(() { + if (selected) { + tempName = preset; + nameController.text = preset; + } + }); + }, + backgroundColor: Theme.of(context).extension()!.surfaceSubtle, + selectedColor: Theme.of(context).extension()!.brandAccent.withValues(alpha: 0.3), + labelStyle: TextStyle( + color: (tempName == preset) + ? Theme.of(context).extension()!.brandPrimary + : Theme.of(context).extension()!.textPrimary, + fontWeight: (tempName == preset) ? FontWeight.bold : null, + ), + ), + ], + ), + const SizedBox(height: 12), + + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: '名称', + hintText: '例: 徳利', + isDense: true, + border: OutlineInputBorder(), + ), + onChanged: (v) => tempName = v, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: TextField( + controller: priceController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '価格', + suffixText: '円', + isDense: true, + border: OutlineInputBorder(), + ), + onChanged: (v) => tempPrice = v, + onSubmitted: (_) => addVariant(), + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(LucideIcons.plus), + label: const Text('リストに追加'), + style: ElevatedButton.styleFrom( + backgroundColor: (tempName.isNotEmpty && tempPrice.isNotEmpty) + ? Theme.of(context).extension()!.brandAccent + : Theme.of(context).extension()!.surfaceSubtle, + foregroundColor: Colors.white, + ), + onPressed: (tempName.isNotEmpty && tempPrice.isNotEmpty) + ? addVariant + : null, + ), + ), + + const SizedBox(height: 16), + + if (variants.isNotEmpty) + Container( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).extension()!.divider), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + ...variants.entries.map((e) => Column( + children: [ + ListTile( + dense: true, + title: Text(e.key, style: const TextStyle(fontWeight: FontWeight.w500)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('${PricingCalculator.formatPrice(e.value)}円', style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + IconButton( + icon: Icon(LucideIcons.x, color: Theme.of(context).extension()!.iconSubtle, size: 18), + onPressed: () { + setModalState(() { + variants.remove(e.key); + }); + }, + ), + ], + ), + ), + if (e.key != variants.keys.last) const Divider(height: 1), + ], + )), + ], + ), + ), + + const SizedBox(height: 24), + + ExpansionTile( + title: Text('原価・掛率設定 (自動計算)', style: TextStyle(fontSize: 14, color: Theme.of(context).extension()!.textSecondary)), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), + child: Column( + children: [ + TextFormField( + initialValue: cost?.toString() ?? '', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '仕入れ値 (円)', + suffixText: '円', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.currency_yen), + ), + onChanged: (v) => setModalState(() => cost = int.tryParse(v)), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('掛率: ${markup.toStringAsFixed(1)}倍'), + TextButton( + child: const Text('リセット'), + onPressed: () => setModalState(() => markup = argProfile.defaultMarkup), + ) + ], + ), + Slider( + value: markup, + min: 1.0, + max: 5.0, + divisions: 40, + label: markup.toStringAsFixed(1), + activeColor: Theme.of(context).extension()!.brandAccent, + onChanged: (v) => setModalState(() => markup = v), + ), + Text( + '参考計算価格: ${PricingCalculator.formatPrice(PricingCalculator.roundUpTo50((cost ?? 0) * markup))}円 (50円切上)', + style: TextStyle(color: Theme.of(context).extension()!.textSecondary, fontSize: 12), + ), + ], + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + ElevatedButton( + onPressed: () { + _updatePricing( + costPrice: cost, + manualPrice: manual, + markup: markup, + priceVariants: variants.isEmpty ? null : variants, + ); + Navigator.pop(context); + }, + child: const Text('保存'), + ), + ], + ); + } + ), + ); + } + + Future _updatePricing({int? costPrice, int? manualPrice, double? markup, Map? priceVariants}) async { + final box = Hive.box('sake_items'); + final newItem = sake.copyWith( + costPrice: costPrice, + manualPrice: manualPrice, + markup: markup ?? sake.userData.markup, + priceVariants: priceVariants, + isUserEdited: true, + ); + + await box.put(sake.key, newItem); + onUpdated(newItem); + } +} diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart index 088700e..80c9545 100644 --- a/lib/screens/sake_detail_screen.dart +++ b/lib/screens/sake_detail_screen.dart @@ -11,9 +11,8 @@ import '../widgets/analyzing_dialog.dart'; import '../widgets/sake_3d_carousel_with_reason.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../providers/sake_list_provider.dart'; -import '../services/pricing_calculator.dart'; import '../providers/theme_provider.dart'; -import '../models/user_profile.dart'; +import 'sake_detail/sections/sake_pricing_section.dart'; import '../theme/app_colors.dart'; import '../constants/app_constants.dart'; import '../widgets/common/munyun_like_button.dart'; @@ -579,20 +578,14 @@ class _SakeDetailScreenState extends ConsumerState { ), ), - // Phase 2-3: Business Pricing Section + // Phase 2-3: Business Pricing Section (Extracted) SliverToBoxAdapter( - child: Consumer( - builder: (context, ref, _) { - final userProfile = ref.watch(userProfileProvider); - if (!userProfile.isBusinessMode) return const SizedBox.shrink(); - - return _buildPricingSection(context, userProfile); - }, + child: SakePricingSection( + sake: _sake, + onUpdated: (updated) => setState(() => _sake = updated), ), ), - // End of Pricing Section - // Gap with Safe Area SliverPadding( padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), @@ -815,340 +808,6 @@ class _SakeDetailScreenState extends ConsumerState { setState(() => _sake = newItem); } - // Phase 2-3: Business Pricing UI (Simplified) - Widget _buildPricingSection(BuildContext context, UserProfile userProfile) { - final appColors = Theme.of(context).extension()!; - - // Calculated Price - final calculatedPrice = PricingCalculator.calculatePrice(_sake); - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Theme.of(context).primaryColor.withValues(alpha: 0.2)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(LucideIcons.coins, color: appColors.brandPrimary, size: 18), - const SizedBox(width: 6), - Text( - '価格設定', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: appColors.brandPrimary), - ), - ], - ), - const SizedBox(height: 4), - Text( - calculatedPrice > 0 - ? '現在$calculatedPrice円' - : '未設定', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: calculatedPrice > 0 - ? appColors.brandPrimary - : appColors.textTertiary, - ), - ), - ], - ), - ElevatedButton( - onPressed: () => _showPriceSettingsDialog(userProfile), - style: ElevatedButton.styleFrom( - backgroundColor: appColors.brandPrimary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - ), - child: const Text('編集'), - ), - ], - ), - ); - } - - Future _showPriceSettingsDialog(UserProfile argProfile) async { - // final userProfile = ref.read(userProfileProvider); // Now passed as arg - if (!argProfile.isBusinessMode) return; - - int? cost = _sake.userData.costPrice; - int? manual = _sake.userData.price; - double markup = _sake.userData.markup; - // Copy existing variants - Map variants = Map.from(_sake.userData.priceVariants ?? {}); - - // Tiny State for Inline Adding - String tempName = ''; - String tempPrice = ''; // String to handle empty better - final TextEditingController nameController = TextEditingController(); - final TextEditingController priceController = TextEditingController(); - - await showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setModalState) { - void addVariant() { - if (tempName.isNotEmpty && tempPrice.isNotEmpty) { - final parsedPrice = int.tryParse(tempPrice); - if (parsedPrice != null) { - setModalState(() { - variants[tempName] = parsedPrice; - // Clear inputs - tempName = ''; - tempPrice = ''; - nameController.clear(); - priceController.clear(); - }); - } - } - } - - return AlertDialog( - title: const Text('価格設定', style: TextStyle(fontWeight: FontWeight.bold)), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 1. Manual Price (Top Priority) - TextFormField( - initialValue: manual?.toString() ?? '', - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '販売価格 (税込)', - hintText: '手動で設定する場合に入力', - suffixText: '円', - border: OutlineInputBorder(), - ), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - onChanged: (v) => setModalState(() => manual = int.tryParse(v)), - ), - const SizedBox(height: 24), - - // 2. Variants (Inline Entry) - const Text('提供サイズ選択', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - - // Presets Chips - Wrap( - spacing: 8.0, - runSpacing: 4.0, - children: [ - for (var preset in ['グラス (90ml)', '一合 (180ml)', 'ボトル (720ml)']) - ChoiceChip( - label: Text(preset), - selected: tempName == preset, - onSelected: (selected) { - setModalState(() { - // Auto-fill logic - if (selected) { - tempName = preset; - nameController.text = preset; - } - }); - }, - backgroundColor: Theme.of(context).extension()!.surfaceSubtle, - selectedColor: Theme.of(context).extension()!.brandAccent.withValues(alpha: 0.3), - labelStyle: TextStyle( - color: (tempName == preset) - ? Theme.of(context).extension()!.brandPrimary - : Theme.of(context).extension()!.textPrimary, - fontWeight: (tempName == preset) ? FontWeight.bold : null, - ), - ), - ], - ), - const SizedBox(height: 12), - - // Inline Inputs - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: TextField( - controller: nameController, - decoration: const InputDecoration( - labelText: '名称', - hintText: '例: 徳利', - isDense: true, - border: OutlineInputBorder(), - ), - onChanged: (v) => tempName = v, - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: TextField( - controller: priceController, // Using controller to clear - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '価格', - suffixText: '円', - isDense: true, - border: OutlineInputBorder(), - ), - onChanged: (v) => tempPrice = v, - ), - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - icon: const Icon(LucideIcons.plus), - label: const Text('リストに追加'), - style: ElevatedButton.styleFrom( - backgroundColor: (tempName.isNotEmpty && tempPrice.isNotEmpty) - ? Theme.of(context).extension()!.brandAccent - : Theme.of(context).extension()!.surfaceSubtle, - foregroundColor: Colors.white, - ), - onPressed: (tempName.isNotEmpty && tempPrice.isNotEmpty) - ? addVariant - : null, - ), - ), - - const SizedBox(height: 16), - - // List of Added Variants - if (variants.isNotEmpty) - Container( - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).extension()!.divider), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - ...variants.entries.map((e) => Column( - children: [ - ListTile( - dense: true, - title: Text(e.key, style: const TextStyle(fontWeight: FontWeight.w500)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('${PricingCalculator.formatPrice(e.value)}円', style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(width: 8), - IconButton( - icon: Icon(LucideIcons.x, color: Theme.of(context).extension()!.iconSubtle, size: 18), - onPressed: () { - setModalState(() { - variants.remove(e.key); - }); - }, - ), - ], - ), - ), - if (e.key != variants.keys.last) const Divider(height: 1), - ], - )), - ], - ), - ), - - const SizedBox(height: 24), - - // 3. Auto Calculation (Accordion) - ExpansionTile( - title: Text('原価・掛率設定 (自動計算)', style: TextStyle(fontSize: 14, color: Theme.of(context).extension()!.textSecondary)), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), - child: Column( - children: [ - TextFormField( - initialValue: cost?.toString() ?? '', - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '仕入れ値 (円)', - suffixText: '円', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.currency_yen), - ), - onChanged: (v) => setModalState(() => cost = int.tryParse(v)), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('掛率: ${markup.toStringAsFixed(1)}倍'), - TextButton( - child: const Text('リセット'), - onPressed: () => setModalState(() => markup = argProfile.defaultMarkup), - ) - ], - ), - Slider( - value: markup, - min: 1.0, - max: 5.0, - divisions: 40, - label: markup.toStringAsFixed(1), - activeColor: Theme.of(context).extension()!.brandAccent, - onChanged: (v) => setModalState(() => markup = v), - ), - Text( - '参考計算価格: ${PricingCalculator.formatPrice(PricingCalculator.roundUpTo50((cost ?? 0) * markup))}円 (50円切上)', - style: TextStyle(color: Theme.of(context).extension()!.textSecondary, fontSize: 12), - ), - ], - ), - ), - ], - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('キャンセル'), - ), - ElevatedButton( - onPressed: () { - _updatePricing( - costPrice: cost, - manualPrice: manual, - markup: markup, - priceVariants: variants.isEmpty ? null : variants, - ); - Navigator.pop(context); - }, - child: const Text('保存'), - ), - ], - ); - } - ), - ); - } - - Future _updatePricing({int? costPrice, int? manualPrice, double? markup, Map? priceVariants}) async { - final box = Hive.box('sake_items'); - final newItem = _sake.copyWith( - costPrice: costPrice, - manualPrice: manualPrice, - markup: markup ?? _sake.userData.markup, - priceVariants: priceVariants, - isUserEdited: true, - ); - - await box.put(_sake.key, newItem); - setState(() => _sake = newItem); - } - Future _showDeleteDialog(BuildContext context) async { final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); diff --git a/pubspec.yaml b/pubspec.yaml index 1b6b742..060acca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.15+26 +version: 1.0.16+27 environment: sdk: ^3.10.1