252 lines
7.6 KiB
Dart
252 lines
7.6 KiB
Dart
/// 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<LineItem?> showItemEditSheet(BuildContext context, {LineItem? item}) {
|
||
return showModalBottomSheet<LineItem>(
|
||
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<ItemEditSheet> createState() => _ItemEditSheetState();
|
||
}
|
||
|
||
class _ItemEditSheetState extends State<ItemEditSheet> {
|
||
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,
|
||
sourceBounds: widget.item?.sourceBounds,
|
||
needsManualReview: false,
|
||
),
|
||
);
|
||
}
|
||
|
||
@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')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|