392 lines
14 KiB
Dart
392 lines
14 KiB
Dart
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;
|
||
// v1.0.12: ExpansionTileController deprecated → ExpansibleController migration
|
||
final ExpansibleController _expansionController = ExpansibleController();
|
||
|
||
// 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,
|
||
// v1.0.12+24: 編集中は折りたたみを無効化(Option A)
|
||
// 編集中にタイトルタップで折りたたみを試みた場合、強制的に展開状態を維持
|
||
onExpansionChanged: (isExpanded) {
|
||
if (_isEditing && !isExpanded) {
|
||
// 編集中は折りたたみを許可しない
|
||
Future.microtask(() => _expansionController.expand());
|
||
}
|
||
},
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|