feat: add EditDialog for receipt item editing and product creation

- Implemented EditDialog widget to facilitate editing of parsed receipt items.
- Added functionality for selecting existing products or creating new ones.
- Integrated category selection for products with a category picker.
- Included utility functions for receipt import, including quantity conversion and package size extraction.
- Enhanced product name normalization and category path lookup for improved user experience.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-03 15:25:56 +02:00
parent dc74a9448b
commit c26d5a4e1d
4 changed files with 1072 additions and 1011 deletions
@@ -0,0 +1,661 @@
import 'package:flutter/material.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../../core/utils/global_error_handler.dart';
import '../../admin/domain/admin_category_node.dart';
import '../data/receipt_import_session.dart';
import '../domain/parsed_receipt_item.dart';
import '../utils/receipt_import_utils.dart';
enum ImportProductEntryMode { existing, create }
typedef _Destination = ImportDestination;
class EditDialog extends StatefulWidget {
final ParsedReceiptItem item;
final ItemEdit current;
final List<ProductOption> products;
final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
final ImportProductEntryMode? initialEntryMode;
const EditDialog({
super.key,
required this.item,
required this.current,
required this.products,
required this.categoryTree,
this.onCreate,
this.initialEntryMode,
});
@override
State<EditDialog> createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
late final TextEditingController _quantityCtrl;
late final TextEditingController _unitCtrl;
late final TextEditingController _packageCountCtrl;
late final TextEditingController _newProductNameCtrl;
int? _productId;
String? _productName;
int? _productCategoryId;
String? _productCategoryPath;
CategorySelectionSource? _productCategorySource;
int? _newCategoryId;
String? _newCategoryPath;
CategorySelectionSource? _newCategorySource;
_Destination _destination = _Destination.inventory;
ImportProductEntryMode _entryMode = ImportProductEntryMode.existing;
bool _isCreatingProduct = false;
// Lokal lista — utökas om nya produkter skapas under dialogen
late List<ProductOption> _localProducts;
late CategoryLookup _lookup;
@override
void initState() {
super.initState();
_lookup = CategoryLookup.fromTree(widget.categoryTree);
_localProducts = List.of(widget.products);
_productId = widget.current.productId;
_productName = widget.current.productName == null
? null
: normalizeProductName(widget.current.productName!);
_destination = widget.current.destination;
_entryMode = widget.initialEntryMode ??
(_productId == null
? ImportProductEntryMode.create
: ImportProductEntryMode.existing);
_productCategoryId =
widget.current.categoryId ?? _categoryIdForProduct(_productId);
_productCategoryPath =
widget.current.categoryPath ?? _lookup.pathFor(_productCategoryId);
_productCategorySource = widget.current.categorySource;
_newCategoryId = widget.current.categoryId ?? widget.item.categorySuggestionId;
_newCategoryPath =
widget.current.categoryPath ?? widget.item.categorySuggestionPath;
_newCategorySource = widget.current.categorySource;
final inferred = inferPackageFields(
rawName: widget.item.rawName,
quantity: widget.current.quantity ?? widget.item.quantity,
unit: widget.current.unit ?? widget.item.unit,
);
_quantityCtrl = TextEditingController(
text: (widget.current.packQuantity ?? inferred.packQuantity)?.toString() ?? '',
);
_unitCtrl = TextEditingController(
text: widget.current.packUnit ?? inferred.packUnit ?? '',
);
_packageCountCtrl = TextEditingController(
text: (widget.current.packageCount ?? inferred.packageCount).toString(),
);
_newProductNameCtrl = TextEditingController(
text: normalizeProductName(widget.current.productName ?? widget.item.rawName),
);
}
@override
void dispose() {
_quantityCtrl.dispose();
_unitCtrl.dispose();
_packageCountCtrl.dispose();
_newProductNameCtrl.dispose();
super.dispose();
}
// ── Hjälpmetoder ──────────────────────────────────────────────────────────
int? _categoryIdForProduct(int? productId) {
if (productId == null) return null;
return _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == productId, orElse: () => null)
?.categoryId;
}
// ── Kategoripicker ─────────────────────────────────────────────────────────
Future<void> _openCreateCategoryPicker({int? preselectedCategoryId}) async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: widget.categoryTree,
preselectedCategoryId:
preselectedCategoryId ?? _newCategoryId ?? widget.item.categorySuggestionId,
);
if (selected == null || !mounted) return;
setState(() {
_newCategoryId = selected.id;
_newCategoryPath = selected.path;
_newCategorySource = CategorySelectionSource.manual;
});
}
Future<void> _openExistingCategoryPicker({int? preselectedCategoryId}) async {
Future<ProductOption?> Function(String, int)? onCreateWrapped;
if (widget.onCreate != null) {
onCreateWrapped = (name, categoryId) async {
final newProduct = await widget.onCreate!(name, categoryId);
if (newProduct != null && mounted) {
setState(() => _localProducts = [..._localProducts, newProduct]);
}
return newProduct;
};
}
final id = await CategoryThenProductPicker.show(
context,
categoryTree: widget.categoryTree,
products: _localProducts,
currentProductId: _productId,
preselectedCategoryId: preselectedCategoryId,
initialQuery: widget.item.rawName,
onCreate: onCreateWrapped,
);
if (id == null || !mounted) return;
setState(() {
_productId = id;
final selectedProduct = _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null);
_productName =
selectedProduct?.name == null ? null : normalizeProductName(selectedProduct!.name);
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _lookup.pathFor(_productCategoryId);
_productCategorySource = CategorySelectionSource.manual;
});
}
/// Applicerar AI-förslag och öppnar kategoriträdet för bekräftelse.
void _applyAiSuggestion() {
int? preselectedCategoryId = widget.item.categorySuggestionId;
final suggestedId = widget.item.suggestedProductId;
if (suggestedId != null) {
setState(() {
_productId = suggestedId;
_productName = widget.item.suggestedProductName == null
? null
: normalizeProductName(widget.item.suggestedProductName!);
_productCategoryId =
_categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId;
_productCategoryPath =
_lookup.pathFor(_productCategoryId) ?? widget.item.categorySuggestionPath;
_productCategorySource = CategorySelectionSource.ai;
});
preselectedCategoryId = _productCategoryId;
}
_openExistingCategoryPicker(preselectedCategoryId: preselectedCategoryId);
}
// ── Spara ──────────────────────────────────────────────────────────────────
bool get _canConfirm {
if (_isCreatingProduct) return false;
if (_entryMode == ImportProductEntryMode.create) return true;
return _productId != null;
}
Future<void> _confirm() async {
if (_entryMode == ImportProductEntryMode.create) {
final trimmedName = _newProductNameCtrl.text.trim();
if (trimmedName.isEmpty) {
showGlobalErrorDialog(context, 'Ange ett produktnamn först.');
return;
}
if (_newCategoryId == null) {
showGlobalErrorDialog(context, 'Välj kategori innan du skapar produkten.');
return;
}
if (widget.onCreate == null) {
showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.');
return;
}
setState(() => _isCreatingProduct = true);
try {
final newProduct = await widget.onCreate!(trimmedName, _newCategoryId!);
if (newProduct == null || !mounted) {
if (mounted) {
showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.');
}
return;
}
if (!_localProducts.any((p) => p.id == newProduct.id)) {
_localProducts = [..._localProducts, newProduct];
}
_productId = newProduct.id;
_productName = newProduct.name;
_productCategoryId = _newCategoryId;
_productCategoryPath = _newCategoryPath;
_productCategorySource = _newCategorySource ?? CategorySelectionSource.manual;
} on ApiException catch (e) {
if (mounted) {
showGlobalErrorDialog(
context,
e.message.trim().isEmpty ? 'Kunde inte skapa produkten. Försök igen.' : e.message,
);
}
return;
} catch (_) {
if (mounted) {
showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.');
}
return;
} finally {
if (mounted) setState(() => _isCreatingProduct = false);
}
if (!mounted || _productId == null) return;
}
final packQuantity =
double.tryParse(_quantityCtrl.text.replaceAll(',', '.'));
final packageCount =
double.tryParse(_packageCountCtrl.text.replaceAll(',', '.')) ?? 1.0;
final packUnit = _unitCtrl.text.trim().isEmpty
? (widget.current.packUnit ?? widget.current.unit ?? widget.item.unit)
: _unitCtrl.text.trim();
final totalQuantity =
packQuantity != null ? packQuantity * packageCount : widget.item.quantity;
Navigator.pop(
context,
ItemEdit(
productId: _productId,
productName: _productName,
categoryId: _productCategoryId,
categoryPath: _productCategoryPath,
categorySource: _productCategorySource,
quantity: totalQuantity,
unit: packUnit,
packQuantity: packQuantity,
packUnit: packUnit,
packageCount: packageCount,
destination: _destination,
),
);
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final item = widget.item;
final aiLabel = _resolveAiLabel(item);
final suggestedProductLabel = _resolveSuggestedProductLabel(item);
final currentPackQuantity =
double.tryParse(_quantityCtrl.text.replaceAll(',', '.'));
final currentPackageCount =
double.tryParse(_packageCountCtrl.text.replaceAll(',', '.')) ?? 1.0;
final currentUnit = _unitCtrl.text.trim().isEmpty
? (widget.current.packUnit ?? widget.current.unit ?? item.unit)
: _unitCtrl.text.trim();
final totalPreview = currentPackQuantity == null
? null
: currentPackQuantity * currentPackageCount;
return AlertDialog(
title: Text(
normalizeProductName(item.rawName),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDestinationPicker(theme),
const SizedBox(height: 12),
_buildEntryModePicker(theme),
const SizedBox(height: 12),
if (_entryMode == ImportProductEntryMode.existing)
_buildExistingProductSection(theme, item, aiLabel, suggestedProductLabel)
else
_buildCreateProductSection(theme, aiLabel),
const SizedBox(height: 12),
if (_destination == _Destination.inventory)
_buildQuantitySection(theme, totalPreview, currentUnit)
else
Text(
'Baslager sparar bara produkt — ingen mängd eller enhet.',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: _canConfirm ? _confirm : null,
child: _isCreatingProduct
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
_entryMode == ImportProductEntryMode.create ? 'Skapa och välj' : 'OK',
),
),
],
);
}
// ── Byggare för delsektioner ───────────────────────────────────────────────
Widget _buildDestinationPicker(ThemeData theme) => SegmentedButton<_Destination>(
segments: const [
ButtonSegment(
value: ImportDestination.inventory,
icon: Icon(Icons.kitchen_outlined, size: 16),
label: Text('Inventarie'),
),
ButtonSegment(
value: ImportDestination.pantry,
icon: Icon(Icons.inventory_2_outlined, size: 16),
label: Text('Baslager'),
),
],
selected: {_destination},
onSelectionChanged: (s) => setState(() => _destination = s.first),
style: const ButtonStyle(visualDensity: VisualDensity.compact),
);
Widget _buildEntryModePicker(ThemeData theme) =>
SegmentedButton<ImportProductEntryMode>(
segments: const [
ButtonSegment(
value: ImportProductEntryMode.existing,
icon: Icon(Icons.search, size: 16),
label: Text('Befintlig'),
),
ButtonSegment(
value: ImportProductEntryMode.create,
icon: Icon(Icons.add_box_outlined, size: 16),
label: Text('Ny produkt'),
),
],
selected: {_entryMode},
onSelectionChanged: (s) => setState(() => _entryMode = s.first),
style: const ButtonStyle(visualDensity: VisualDensity.compact),
);
Widget _buildExistingProductSection(
ThemeData theme,
ParsedReceiptItem item,
String? aiLabel,
String? suggestedProductLabel,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (suggestedProductLabel != null) ...[
Tooltip(
message: 'Trolig matchning baserat på produktnamn i databasen',
child: ActionChip(
avatar: Icon(Icons.search, size: 14, color: Colors.blue.shade700),
label: Text('Namnförslag: $suggestedProductLabel',
style: Theme.of(context).textTheme.labelSmall),
backgroundColor: Colors.blue.shade50,
side: BorderSide(color: Colors.blue.shade300),
visualDensity: VisualDensity.compact,
onPressed: _applyAiSuggestion,
),
),
const SizedBox(height: 8),
],
Row(
children: [
Expanded(
child: ProductPickerField(
products: _localProducts,
value: _productId,
label: 'Produkt',
initialQuery: item.rawName,
onChanged: (id) {
setState(() {
_productId = id;
final selectedName = _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productName = selectedName == null
? null
: normalizeProductName(selectedName);
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _lookup.pathFor(_productCategoryId);
_productCategorySource =
id == null ? null : CategorySelectionSource.manual;
});
},
),
),
const SizedBox(width: 8),
Tooltip(
message: 'Välj via kategori',
child: OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 56),
padding: EdgeInsets.zero,
),
onPressed: _openExistingCategoryPicker,
child: const Icon(Icons.account_tree_outlined, size: 20),
),
),
],
),
if (_productCategoryPath != null) ...[
const SizedBox(height: 8),
ActionChip(
avatar: Icon(Icons.account_tree_outlined,
size: 14, color: Theme.of(context).colorScheme.primary),
label: Text('Kategori: $_productCategoryPath',
style: Theme.of(context).textTheme.labelSmall,
overflow: TextOverflow.ellipsis),
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
visualDensity: VisualDensity.compact,
onPressed: () =>
_openExistingCategoryPicker(preselectedCategoryId: _productCategoryId),
),
],
if (aiLabel != null) ...[
const SizedBox(height: 8),
ActionChip(
avatar: Icon(Icons.auto_awesome, size: 14, color: Colors.green.shade700),
label: Text('AI-kategori: $aiLabel',
style: Theme.of(context).textTheme.labelSmall),
backgroundColor: Colors.green.shade50,
side: BorderSide(color: Colors.green.shade300),
visualDensity: VisualDensity.compact,
onPressed: () => _openExistingCategoryPicker(
preselectedCategoryId: item.categorySuggestionId,
),
),
],
],
);
}
Widget _buildCreateProductSection(ThemeData theme, String? aiLabel) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _newProductNameCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Produktnamn',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openCreateCategoryPicker,
icon: const Icon(Icons.account_tree_outlined),
label: Text(
_newCategoryPath == null ? 'Välj kategori' : 'Kategori: $_newCategoryPath',
overflow: TextOverflow.ellipsis,
),
),
),
if (_newCategoryPath != null) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vald kategori',
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 4),
Text(
_newCategoryPath!,
style:
theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
if (aiLabel != null) ...[
const SizedBox(height: 8),
Wrap(
children: [
ActionChip(
avatar: Icon(Icons.auto_awesome,
size: 14, color: Colors.green.shade700),
label: Text('AI-förslag: $aiLabel',
style: Theme.of(context).textTheme.labelSmall),
backgroundColor: Colors.green.shade50,
side: BorderSide(color: Colors.green.shade300),
visualDensity: VisualDensity.compact,
onPressed: widget.item.categorySuggestionId == null
? null
: () => _openCreateCategoryPicker(
preselectedCategoryId: widget.item.categorySuggestionId,
),
),
],
),
],
],
],
);
}
Widget _buildQuantitySection(
ThemeData theme,
double? totalPreview,
String? currentUnit,
) {
return Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _quantityCtrl,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Mängd per förpackning',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _unitCtrl,
decoration: const InputDecoration(
labelText: 'Enhet',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _packageCountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Antal förpackningar',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
if (totalPreview != null &&
currentUnit != null &&
currentUnit.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.green.shade200),
),
child: Text(
'Totalt: ${formatSwedishNumber(totalPreview)} $currentUnit '
'(mängd × antal förpackningar).',
style: theme.textTheme.bodySmall
?.copyWith(color: Colors.green.shade800),
),
),
],
],
);
}
// ── Statiska hjälpare ──────────────────────────────────────────────────────
static String? _resolveAiLabel(ParsedReceiptItem item) {
final path = item.categorySuggestionPath;
if (path != null && path.isNotEmpty) return path;
final name = item.categorySuggestionName;
if (name != null && name.isNotEmpty) return name;
return null;
}
static String? _resolveSuggestedProductLabel(ParsedReceiptItem item) {
final suggested = item.suggestedProductName;
if (suggested != null && suggested.isNotEmpty) {
return normalizeProductName(suggested);
}
final matched = item.matchedProductName;
if (matched != null && matched.isNotEmpty) {
return normalizeProductName(matched);
}
return null;
}
}