352 lines
15 KiB
Dart
352 lines
15 KiB
Dart
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);
|
|
}
|
|
}
|