2026-01-29 15:54:22 +00:00
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
|
|
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
|
|
|
|
import '../../models/sake_item.dart';
|
|
|
|
|
|
import '../../theme/app_colors.dart';
|
|
|
|
|
|
import 'package:hive_flutter/hive_flutter.dart';
|
|
|
|
|
|
|
|
|
|
|
|
class SakeDetailSpecs extends StatefulWidget {
|
|
|
|
|
|
final SakeItem sake;
|
|
|
|
|
|
final ValueChanged<SakeItem> onUpdate;
|
|
|
|
|
|
|
|
|
|
|
|
const SakeDetailSpecs({
|
|
|
|
|
|
super.key,
|
|
|
|
|
|
required this.sake,
|
|
|
|
|
|
required this.onUpdate,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<SakeDetailSpecs> createState() => _SakeDetailSpecsState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
|
|
|
|
|
bool _isEditing = false;
|
2026-02-15 15:13:12 +00:00
|
|
|
|
// v1.0.12: ExpansionTileController deprecated → ExpansibleController migration
|
|
|
|
|
|
final ExpansibleController _expansionController = ExpansibleController();
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
|
|
|
|
|
// Controllers
|
|
|
|
|
|
late final TextEditingController _typeController;
|
|
|
|
|
|
late final TextEditingController _polishingController;
|
|
|
|
|
|
late final TextEditingController _alcoholController;
|
|
|
|
|
|
late final TextEditingController _sakeMeterController;
|
|
|
|
|
|
late final TextEditingController _riceController;
|
|
|
|
|
|
late final TextEditingController _yeastController;
|
|
|
|
|
|
late final TextEditingController _manufacturingController;
|
|
|
|
|
|
|
|
|
|
|
|
// Unused in UI currently but reserved
|
|
|
|
|
|
// TODO: Phase X で甘味度・ボディスコアの編集UIを追加する予定
|
|
|
|
|
|
/*
|
|
|
|
|
|
late final TextEditingController _sweetnessController;
|
|
|
|
|
|
late final TextEditingController _bodyController;
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
_initControllers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _initControllers() {
|
|
|
|
|
|
final specs = widget.sake.hiddenSpecs;
|
|
|
|
|
|
_typeController = TextEditingController(text: specs.type);
|
|
|
|
|
|
_polishingController =
|
|
|
|
|
|
TextEditingController(text: specs.polishingRatio?.toString() ?? '');
|
|
|
|
|
|
_alcoholController =
|
|
|
|
|
|
TextEditingController(text: specs.alcoholContent?.toString() ?? '');
|
|
|
|
|
|
_sakeMeterController =
|
|
|
|
|
|
TextEditingController(text: specs.sakeMeterValue?.toString() ?? '');
|
|
|
|
|
|
_riceController = TextEditingController(text: specs.riceVariety);
|
|
|
|
|
|
_yeastController = TextEditingController(text: specs.yeast);
|
|
|
|
|
|
_manufacturingController =
|
|
|
|
|
|
TextEditingController(text: specs.manufacturingYearMonth);
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
_sweetnessController = TextEditingController(
|
|
|
|
|
|
text: specs.sweetnessScore?.toStringAsFixed(1) ?? '');
|
|
|
|
|
|
_bodyController =
|
|
|
|
|
|
TextEditingController(text: specs.bodyScore?.toStringAsFixed(1) ?? '');
|
|
|
|
|
|
*/
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void didUpdateWidget(SakeDetailSpecs oldWidget) {
|
|
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
|
|
if (oldWidget.sake != widget.sake) {
|
|
|
|
|
|
if (_isEditing) {
|
|
|
|
|
|
// Warn user about external update while editing
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(
|
|
|
|
|
|
content: Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
|
|
|
|
|
|
backgroundColor: Colors.orange,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
_cancel(); // Force exit edit mode
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Update values without disposing controllers
|
|
|
|
|
|
_updateControllers();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _updateControllers() {
|
|
|
|
|
|
final specs = widget.sake.hiddenSpecs;
|
|
|
|
|
|
_updateTextIfNotEditing(_typeController, specs.type ?? '');
|
|
|
|
|
|
_updateTextIfNotEditing(_polishingController, specs.polishingRatio?.toString() ?? '');
|
|
|
|
|
|
_updateTextIfNotEditing(_alcoholController, specs.alcoholContent?.toString() ?? '');
|
|
|
|
|
|
_updateTextIfNotEditing(_sakeMeterController, specs.sakeMeterValue?.toString() ?? '');
|
|
|
|
|
|
_updateTextIfNotEditing(_riceController, specs.riceVariety ?? '');
|
|
|
|
|
|
_updateTextIfNotEditing(_yeastController, specs.yeast ?? '');
|
|
|
|
|
|
_updateTextIfNotEditing(_manufacturingController, specs.manufacturingYearMonth ?? '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _updateTextIfNotEditing(TextEditingController controller, String newValue) {
|
|
|
|
|
|
if (controller.text != newValue) {
|
|
|
|
|
|
controller.text = newValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _disposeControllers() {
|
|
|
|
|
|
_typeController.dispose();
|
|
|
|
|
|
_polishingController.dispose();
|
|
|
|
|
|
_alcoholController.dispose();
|
|
|
|
|
|
_sakeMeterController.dispose();
|
|
|
|
|
|
_riceController.dispose();
|
|
|
|
|
|
_yeastController.dispose();
|
|
|
|
|
|
_manufacturingController.dispose();
|
|
|
|
|
|
// _sweetnessController.dispose();
|
|
|
|
|
|
// _bodyController.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
_disposeControllers();
|
|
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _save() async {
|
|
|
|
|
|
final box = Hive.box<SakeItem>('sake_items');
|
|
|
|
|
|
|
|
|
|
|
|
final updated = widget.sake.copyWith(
|
|
|
|
|
|
specificDesignation: _typeController.text,
|
|
|
|
|
|
polishingRatio: int.tryParse(_polishingController.text),
|
|
|
|
|
|
alcoholContent: double.tryParse(_alcoholController.text),
|
|
|
|
|
|
sakeMeterValue: double.tryParse(_sakeMeterController.text),
|
|
|
|
|
|
riceVariety: _riceController.text,
|
|
|
|
|
|
yeast: _yeastController.text,
|
|
|
|
|
|
manufacturingYearMonth: _manufacturingController.text,
|
|
|
|
|
|
isUserEdited: true,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await box.put(widget.sake.key, updated);
|
|
|
|
|
|
widget.onUpdate(updated);
|
|
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_isEditing = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AI分析情報の編集をキャンセル
|
|
|
|
|
|
void _cancel() {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_isEditing = false;
|
|
|
|
|
|
// コントローラーを元の値にリセット
|
|
|
|
|
|
_updateControllers();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
if (widget.sake.itemType == ItemType.set) return const SizedBox.shrink();
|
|
|
|
|
|
|
|
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
|
|
|
|
|
final textColor = Theme.of(context).brightness == Brightness.dark ? Colors.white : null;
|
|
|
|
|
|
|
|
|
|
|
|
return Theme(
|
|
|
|
|
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
|
|
|
|
|
child: ExpansionTile(
|
|
|
|
|
|
controller: _expansionController,
|
2026-02-15 15:13:12 +00:00
|
|
|
|
// v1.0.12+24: 編集中は折りたたみを無効化(Option A)
|
|
|
|
|
|
// 編集中にタイトルタップで折りたたみを試みた場合、強制的に展開状態を維持
|
|
|
|
|
|
onExpansionChanged: (isExpanded) {
|
|
|
|
|
|
if (_isEditing && !isExpanded) {
|
|
|
|
|
|
// 編集中は折りたたみを許可しない
|
|
|
|
|
|
Future.microtask(() => _expansionController.expand());
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-01-29 15:54:22 +00:00
|
|
|
|
leading: Icon(LucideIcons.sparkles, color: appColors.iconDefault),
|
|
|
|
|
|
title: Text(
|
|
|
|
|
|
'詳細',
|
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
|
color: textColor,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
trailing: _isEditing
|
|
|
|
|
|
? Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: _cancel,
|
|
|
|
|
|
child: const Text('キャンセル'),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
FilledButton(
|
|
|
|
|
|
onPressed: _save,
|
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
|
horizontal: 12, vertical: 8),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: const Text('保存'),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
: IconButton(
|
|
|
|
|
|
icon: const Icon(LucideIcons.edit2, size: 18),
|
|
|
|
|
|
tooltip: '編集',
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
setState(() => _isEditing = true);
|
|
|
|
|
|
_expansionController.expand();
|
|
|
|
|
|
},
|
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
|
|
|
|
),
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildEditableSpecRow(
|
|
|
|
|
|
context, '特定名称', _typeController, _isEditing),
|
|
|
|
|
|
_buildEditableSpecRow(
|
|
|
|
|
|
context,
|
|
|
|
|
|
'精米歩合',
|
|
|
|
|
|
_polishingController,
|
|
|
|
|
|
_isEditing,
|
|
|
|
|
|
keyboardType: TextInputType.number,
|
|
|
|
|
|
suffix: '%',
|
|
|
|
|
|
),
|
|
|
|
|
|
_buildEditableSpecRow(
|
|
|
|
|
|
context,
|
|
|
|
|
|
'アルコール分',
|
|
|
|
|
|
_alcoholController,
|
|
|
|
|
|
_isEditing,
|
|
|
|
|
|
keyboardType: TextInputType.number,
|
|
|
|
|
|
suffix: '度',
|
|
|
|
|
|
),
|
|
|
|
|
|
_buildEditableSpecRow(
|
|
|
|
|
|
context,
|
|
|
|
|
|
'日本酒度',
|
|
|
|
|
|
_sakeMeterController,
|
|
|
|
|
|
_isEditing,
|
|
|
|
|
|
keyboardType:
|
|
|
|
|
|
const TextInputType.numberWithOptions(signed: true),
|
|
|
|
|
|
),
|
|
|
|
|
|
_buildEditableSpecRow(
|
|
|
|
|
|
context, '酒米', _riceController, _isEditing),
|
|
|
|
|
|
_buildEditableSpecRow(
|
|
|
|
|
|
context, '酵母', _yeastController, _isEditing),
|
|
|
|
|
|
_buildEditableSpecRow(
|
|
|
|
|
|
context,
|
|
|
|
|
|
'製造年月',
|
|
|
|
|
|
_manufacturingController,
|
|
|
|
|
|
_isEditing,
|
|
|
|
|
|
suffixIcon: LucideIcons.calendar,
|
|
|
|
|
|
onSuffixTap: () => _showDatePicker(context),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _showDatePicker(BuildContext context) {
|
|
|
|
|
|
if (!_isEditing) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Parse current value or use now
|
|
|
|
|
|
DateTime initialDate = DateTime.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
final parts = _manufacturingController.text.split('-');
|
|
|
|
|
|
if (parts.length >= 2) {
|
|
|
|
|
|
final year = int.parse(parts[0]);
|
|
|
|
|
|
final month = int.parse(parts[1]);
|
|
|
|
|
|
initialDate = DateTime(year, month);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
|
|
showCupertinoModalPopup<void>(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
builder: (BuildContext context) => Container(
|
|
|
|
|
|
height: 280,
|
|
|
|
|
|
padding: const EdgeInsets.only(top: 6.0),
|
|
|
|
|
|
margin: EdgeInsets.only(
|
|
|
|
|
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
|
|
|
|
),
|
|
|
|
|
|
color: CupertinoColors.systemBackground.resolveFrom(context),
|
|
|
|
|
|
child: SafeArea(
|
|
|
|
|
|
top: false,
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
CupertinoButton(
|
|
|
|
|
|
child: const Text('キャンセル'),
|
|
|
|
|
|
onPressed: () => Navigator.pop(context),
|
|
|
|
|
|
),
|
|
|
|
|
|
CupertinoButton(
|
|
|
|
|
|
child: const Text('完了'),
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
Navigator.pop(context);
|
|
|
|
|
|
// Update Text Field (YYYY-MM)
|
|
|
|
|
|
// Note: Simply using the variable won't work because callback is needed?
|
|
|
|
|
|
// Actually we can update controller here directly
|
|
|
|
|
|
// But wait, the picker value changes. We need state for temp value?
|
|
|
|
|
|
// CupertinoDatePicker onDateTimeChanged is consistent.
|
|
|
|
|
|
// We update controller in onDateTimeChanged, or store in temp?
|
|
|
|
|
|
// Updating controller directly is fine as long as we don't save yet.
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: CupertinoDatePicker(
|
|
|
|
|
|
initialDateTime: initialDate,
|
|
|
|
|
|
mode: CupertinoDatePickerMode.monthYear,
|
|
|
|
|
|
use24hFormat: true,
|
|
|
|
|
|
// This is called every time the user scrolls the picker
|
|
|
|
|
|
onDateTimeChanged: (DateTime newDate) {
|
|
|
|
|
|
final text = "${newDate.year}-${newDate.month.toString().padLeft(2, '0')}";
|
|
|
|
|
|
_updateTextIfNotEditing(_manufacturingController, text);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildEditableSpecRow(
|
|
|
|
|
|
BuildContext context,
|
|
|
|
|
|
String label,
|
|
|
|
|
|
TextEditingController controller,
|
|
|
|
|
|
bool isEditing, {
|
|
|
|
|
|
TextInputType keyboardType = TextInputType.text,
|
|
|
|
|
|
String? suffix,
|
|
|
|
|
|
String? helperText,
|
|
|
|
|
|
IconData? suffixIcon,
|
|
|
|
|
|
VoidCallback? onSuffixTap,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
|
|
|
|
|
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
SizedBox(
|
|
|
|
|
|
width: 100,
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
label,
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
color: appColors.textSecondary,
|
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: isEditing
|
|
|
|
|
|
? TextField(
|
|
|
|
|
|
controller: controller,
|
|
|
|
|
|
keyboardType: keyboardType,
|
|
|
|
|
|
style: const TextStyle(fontSize: 14),
|
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
|
isDense: true,
|
|
|
|
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
|
|
|
|
horizontal: 8, vertical: 8),
|
|
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
|
suffixText: suffix,
|
|
|
|
|
|
helperText: helperText,
|
|
|
|
|
|
suffixIcon: suffixIcon != null
|
|
|
|
|
|
? IconButton(
|
|
|
|
|
|
icon: Icon(suffixIcon, size: 18),
|
|
|
|
|
|
onPressed: onSuffixTap,
|
|
|
|
|
|
)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
: Text(
|
|
|
|
|
|
(controller.text.isEmpty) ? '-' : '${controller.text}${suffix ?? ""}',
|
|
|
|
|
|
style: const TextStyle(fontSize: 14),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|