/// Bottom sheet to add or edit a priced line item. library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/line_item.dart'; import '../utils/money.dart'; Future showItemEditSheet(BuildContext context, {LineItem? item}) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) => ItemEditSheet(item: item), ); } class ItemEditSheet extends StatefulWidget { const ItemEditSheet({super.key, this.item}); final LineItem? item; @override State createState() => _ItemEditSheetState(); } class _ItemEditSheetState extends State { late final TextEditingController _nameController; late final TextEditingController _quantityController; late final TextEditingController _unitPriceController; late final TextEditingController _totalController; String? _error; bool _syncing = false; @override void initState() { super.initState(); final item = widget.item; final quantity = item?.quantity ?? 1; final total = item?.price; final unit = item?.unitPrice ?? (total != null && quantity > 0 ? roundMoney(total / quantity) : null); _nameController = TextEditingController(text: item?.name ?? ''); _quantityController = TextEditingController(text: '$quantity'); _unitPriceController = TextEditingController( text: unit == null ? '' : unit.toStringAsFixed(2), ); _totalController = TextEditingController( text: total == null ? '' : total.toStringAsFixed(2), ); _quantityController.addListener(_onQuantityChanged); _unitPriceController.addListener(_onUnitPriceChanged); _totalController.addListener(_onTotalChanged); } @override void dispose() { _quantityController.removeListener(_onQuantityChanged); _unitPriceController.removeListener(_onUnitPriceChanged); _totalController.removeListener(_onTotalChanged); _nameController.dispose(); _quantityController.dispose(); _unitPriceController.dispose(); _totalController.dispose(); super.dispose(); } int? get _quantity { final raw = _quantityController.text.trim(); if (raw.isEmpty) return null; return int.tryParse(raw); } void _setText(TextEditingController controller, String value) { if (controller.text == value) return; controller.value = TextEditingValue( text: value, selection: TextSelection.collapsed(offset: value.length), ); } void _onQuantityChanged() { if (_syncing) return; final quantity = _quantity; if (quantity == null || quantity <= 0) { setState(() {}); return; } final unit = tryParsePrice(_unitPriceController.text); final total = tryParsePrice(_totalController.text); _syncing = true; if (unit != null) { _setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2)); } else if (total != null) { _setText( _unitPriceController, roundMoney(total / quantity).toStringAsFixed(2), ); } _syncing = false; setState(() {}); } void _onUnitPriceChanged() { if (_syncing) return; final quantity = _quantity; final unit = tryParsePrice(_unitPriceController.text); if (quantity == null || quantity <= 0 || unit == null) { setState(() {}); return; } _syncing = true; _setText(_totalController, roundMoney(quantity * unit).toStringAsFixed(2)); _syncing = false; setState(() {}); } void _onTotalChanged() { if (_syncing) return; final quantity = _quantity; final total = tryParsePrice(_totalController.text); if (quantity == null || quantity <= 0 || total == null) { setState(() {}); return; } _syncing = true; _setText( _unitPriceController, roundMoney(total / quantity).toStringAsFixed(2), ); _syncing = false; setState(() {}); } void _save() { final name = _nameController.text.trim(); final quantity = _quantity; final unit = tryParsePrice(_unitPriceController.text); final total = tryParsePrice(_totalController.text); if (name.isEmpty) { setState(() => _error = 'Enter a product name.'); return; } if (quantity == null || quantity <= 0) { setState(() => _error = 'Enter a quantity of at least 1.'); return; } if (total == null && unit == null) { setState(() => _error = 'Enter a unit price or a total.'); return; } final resolvedTotal = total ?? roundMoney(quantity * unit!); final resolvedUnit = unit ?? roundMoney(resolvedTotal / quantity); final hasMulti = quantity > 1; Navigator.of(context).pop( LineItem( id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}', name: name, price: roundMoney(resolvedTotal), quantity: hasMulti ? quantity : null, unitPrice: hasMulti ? roundMoney(resolvedUnit) : null, discount: widget.item?.price == roundMoney(resolvedTotal) ? widget.item?.discount ?? 0 : 0, ), ); } @override Widget build(BuildContext context) { final bottom = MediaQuery.viewInsetsOf(context).bottom; final quantity = _quantity; final unit = tryParsePrice(_unitPriceController.text); final preview = quantity != null && quantity > 1 && unit != null ? '$quantity × ${formatEuro(unit)}' : null; return Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( widget.item == null ? 'Add item' : 'Edit item', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), TextField( controller: _nameController, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration(labelText: 'Product name'), autofocus: widget.item == null, ), const SizedBox(height: 12), Row( children: [ SizedBox( width: 88, child: TextField( controller: _quantityController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration(labelText: 'Qty'), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: _unitPriceController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration(labelText: 'Unit price'), ), ), ], ), const SizedBox(height: 12), TextField( controller: _totalController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( labelText: 'Total price', helperText: preview, ), ), if (_error != null) ...[ const SizedBox(height: 8), Text( _error!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ], const SizedBox(height: 16), FilledButton(onPressed: _save, child: const Text('Save')), ], ), ); } }