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:
parent
3502694d89
commit
1a50c739a1
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,9 +11,8 @@ import '../widgets/analyzing_dialog.dart';
|
||||||
import '../widgets/sake_3d_carousel_with_reason.dart';
|
import '../widgets/sake_3d_carousel_with_reason.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import '../providers/sake_list_provider.dart';
|
import '../providers/sake_list_provider.dart';
|
||||||
import '../services/pricing_calculator.dart';
|
|
||||||
import '../providers/theme_provider.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 '../theme/app_colors.dart';
|
||||||
import '../constants/app_constants.dart';
|
import '../constants/app_constants.dart';
|
||||||
import '../widgets/common/munyun_like_button.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(
|
SliverToBoxAdapter(
|
||||||
child: Consumer(
|
child: SakePricingSection(
|
||||||
builder: (context, ref, _) {
|
sake: _sake,
|
||||||
final userProfile = ref.watch(userProfileProvider);
|
onUpdated: (updated) => setState(() => _sake = updated),
|
||||||
if (!userProfile.isBusinessMode) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return _buildPricingSection(context, userProfile);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// End of Pricing Section
|
|
||||||
|
|
||||||
// Gap with Safe Area
|
// Gap with Safe Area
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
|
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
|
||||||
|
|
@ -815,340 +808,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
setState(() => _sake = newItem);
|
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 {
|
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.10.1
|
sdk: ^3.10.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue