ponshu-room-lite/lib/widgets/sake_detail/sake_detail_specs.dart

392 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
),
],
),
);
}
}