refactor: Extract SakePricingSection from sake_detail_screen.dart, bump to v1.0.16+27

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ponshu Developer 2026-02-16 11:40:58 +09:00
parent 3502694d89
commit 1a50c739a1
3 changed files with 357 additions and 347 deletions

View File

@ -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<SakeItem> 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<AppColors>()!;
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<void> _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<String, int> 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<AppColors>()!.surfaceSubtle,
selectedColor: Theme.of(context).extension<AppColors>()!.brandAccent.withValues(alpha: 0.3),
labelStyle: TextStyle(
color: (tempName == preset)
? Theme.of(context).extension<AppColors>()!.brandPrimary
: Theme.of(context).extension<AppColors>()!.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<AppColors>()!.brandAccent
: Theme.of(context).extension<AppColors>()!.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<AppColors>()!.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<AppColors>()!.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<AppColors>()!.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<AppColors>()!.brandAccent,
onChanged: (v) => setModalState(() => markup = v),
),
Text(
'参考計算価格: ${PricingCalculator.formatPrice(PricingCalculator.roundUpTo50((cost ?? 0) * markup))}円 (50円切上)',
style: TextStyle(color: Theme.of(context).extension<AppColors>()!.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<void> _updatePricing({int? costPrice, int? manualPrice, double? markup, Map<String, int>? priceVariants}) async {
final box = Hive.box<SakeItem>('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);
}
}

View File

@ -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<SakeDetailScreen> {
),
),
// 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<SakeDetailScreen> {
setState(() => _sake = newItem);
}
// Phase 2-3: Business Pricing UI (Simplified)
Widget _buildPricingSection(BuildContext context, UserProfile userProfile) {
final appColors = Theme.of(context).extension<AppColors>()!;
// 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<void> _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<String, int> 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<AppColors>()!.surfaceSubtle,
selectedColor: Theme.of(context).extension<AppColors>()!.brandAccent.withValues(alpha: 0.3),
labelStyle: TextStyle(
color: (tempName == preset)
? Theme.of(context).extension<AppColors>()!.brandPrimary
: Theme.of(context).extension<AppColors>()!.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<AppColors>()!.brandAccent
: Theme.of(context).extension<AppColors>()!.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<AppColors>()!.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<AppColors>()!.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<AppColors>()!.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<AppColors>()!.brandAccent,
onChanged: (v) => setModalState(() => markup = v),
),
Text(
'参考計算価格: ${PricingCalculator.formatPrice(PricingCalculator.roundUpTo50((cost ?? 0) * markup))}円 (50円切上)',
style: TextStyle(color: Theme.of(context).extension<AppColors>()!.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<void> _updatePricing({int? costPrice, int? manualPrice, double? markup, Map<String, int>? priceVariants}) async {
final box = Hive.box<SakeItem>('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<void> _showDeleteDialog(BuildContext context) async {
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);

View File

@ -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