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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/api/api_paths.dart';
|
||||
import '../../../core/api/api_providers.dart';
|
||||
import '../../../core/ui/category_then_product_picker.dart';
|
||||
import '../../../core/ui/product_picker_field.dart';
|
||||
import '../../../core/utils/pdf_opener.dart';
|
||||
import '../../../core/utils/global_error_handler.dart';
|
||||
import '../../admin/data/admin_repository.dart';
|
||||
@@ -18,791 +15,15 @@ import '../../pantry/domain/pantry_item.dart';
|
||||
import '../data/import_providers.dart';
|
||||
import '../data/receipt_import_session.dart';
|
||||
import '../domain/parsed_receipt_item.dart';
|
||||
import '../utils/receipt_import_utils.dart';
|
||||
import 'edit_dialog.dart';
|
||||
|
||||
typedef _Destination = ImportDestination;
|
||||
|
||||
enum _ProductEntryMode { existing, create }
|
||||
|
||||
bool _isPackageLikeUnit(String? unit) {
|
||||
if (unit == null) return false;
|
||||
const packageUnits = {
|
||||
'paket',
|
||||
'forpackning',
|
||||
'forp',
|
||||
'forp.',
|
||||
'förpackning',
|
||||
'förp',
|
||||
'förp.',
|
||||
'fp',
|
||||
'pkt',
|
||||
'pack',
|
||||
'pak',
|
||||
'st',
|
||||
'styck',
|
||||
};
|
||||
return packageUnits.contains(unit.trim().toLowerCase());
|
||||
}
|
||||
|
||||
({double packQuantity, String packUnit})? _extractPackageSizeFromRawName(
|
||||
String rawName,
|
||||
) {
|
||||
final match = RegExp(
|
||||
r'(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(rawName);
|
||||
if (match == null) return null;
|
||||
final value = double.tryParse(match.group(1)!.replaceAll(',', '.'));
|
||||
final sizeUnit = match.group(2)!.toLowerCase();
|
||||
if (value == null) return null;
|
||||
return (packQuantity: value, packUnit: sizeUnit);
|
||||
}
|
||||
|
||||
({double? packQuantity, String? packUnit, double packageCount, double? totalQuantity, String? totalUnit})
|
||||
_inferPackageFields({
|
||||
required String rawName,
|
||||
required double? quantity,
|
||||
required String? unit,
|
||||
}) {
|
||||
final normalizedUnit = unit?.trim().toLowerCase();
|
||||
final safeCount = (quantity != null && quantity > 0) ? quantity : 1.0;
|
||||
final extracted = _extractPackageSizeFromRawName(rawName);
|
||||
|
||||
// If the receipt name contains size (e.g. "5dl"), prefer it when unit is
|
||||
// missing/unknown or when OCR reports package-like count units (st/pkt/etc).
|
||||
if (extracted != null && (normalizedUnit == null || normalizedUnit.isEmpty || _isPackageLikeUnit(normalizedUnit))) {
|
||||
return (
|
||||
packQuantity: extracted.packQuantity,
|
||||
packUnit: extracted.packUnit,
|
||||
packageCount: safeCount,
|
||||
totalQuantity: extracted.packQuantity * safeCount,
|
||||
totalUnit: extracted.packUnit,
|
||||
);
|
||||
}
|
||||
|
||||
if (quantity == null || normalizedUnit == null || normalizedUnit.isEmpty) {
|
||||
return (
|
||||
packQuantity: null,
|
||||
packUnit: null,
|
||||
packageCount: 1,
|
||||
totalQuantity: quantity,
|
||||
totalUnit: unit,
|
||||
);
|
||||
}
|
||||
|
||||
final looksLikePackage = _isPackageLikeUnit(normalizedUnit);
|
||||
|
||||
if (looksLikePackage && extracted != null) {
|
||||
return (
|
||||
packQuantity: extracted.packQuantity,
|
||||
packUnit: extracted.packUnit,
|
||||
packageCount: quantity,
|
||||
totalQuantity: extracted.packQuantity * quantity,
|
||||
totalUnit: extracted.packUnit,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
packQuantity: quantity,
|
||||
packUnit: normalizedUnit,
|
||||
packageCount: 1,
|
||||
totalQuantity: quantity,
|
||||
totalUnit: normalizedUnit,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCompactNumber(double value) {
|
||||
if (value == value.roundToDouble()) return value.toStringAsFixed(0);
|
||||
final formatted = value.toStringAsFixed(3);
|
||||
return formatted
|
||||
.replaceFirst(RegExp(r'0+$'), '')
|
||||
.replaceFirst(RegExp(r'\.$'), '');
|
||||
}
|
||||
|
||||
String _formatSwedishNumber(double value) {
|
||||
return _formatCompactNumber(value).replaceAll('.', ',');
|
||||
}
|
||||
|
||||
double? _convertQuantity(double quantity, String fromUnit, String toUnit) {
|
||||
final from = fromUnit.trim().toLowerCase();
|
||||
final to = toUnit.trim().toLowerCase();
|
||||
if (from.isEmpty || to.isEmpty) return null;
|
||||
if (from == to) return quantity;
|
||||
|
||||
// Mass
|
||||
if (from == 'mg' && to == 'g') return quantity / 1000.0;
|
||||
if (from == 'mg' && to == 'kg') return quantity / 1000000.0;
|
||||
if (from == 'mg' && to == 'hg') return quantity / 100000.0;
|
||||
|
||||
if (from == 'g' && to == 'mg') return quantity * 1000.0;
|
||||
if (from == 'g' && to == 'hg') return quantity / 100.0;
|
||||
if (from == 'g' && to == 'kg') return quantity / 1000.0;
|
||||
|
||||
if (from == 'hg' && to == 'mg') return quantity * 100000.0;
|
||||
if (from == 'hg' && to == 'g') return quantity * 100.0;
|
||||
if (from == 'hg' && to == 'kg') return quantity / 10.0;
|
||||
|
||||
if (from == 'kg' && to == 'mg') return quantity * 1000000.0;
|
||||
if (from == 'kg' && to == 'hg') return quantity * 10.0;
|
||||
if (from == 'kg' && to == 'g') return quantity * 1000.0;
|
||||
|
||||
// Volume
|
||||
if (from == 'ml' && to == 'l') return quantity / 1000.0;
|
||||
if (from == 'cl' && to == 'l') return quantity / 100.0;
|
||||
if (from == 'dl' && to == 'l') return quantity / 10.0;
|
||||
if (from == 'l' && to == 'ml') return quantity * 1000.0;
|
||||
if (from == 'l' && to == 'cl') return quantity * 100.0;
|
||||
if (from == 'l' && to == 'dl') return quantity * 10.0;
|
||||
|
||||
// Intra-volume conversions
|
||||
if (from == 'ml' && to == 'cl') return quantity / 10.0;
|
||||
if (from == 'ml' && to == 'dl') return quantity / 100.0;
|
||||
if (from == 'cl' && to == 'ml') return quantity * 10.0;
|
||||
if (from == 'cl' && to == 'dl') return quantity / 10.0;
|
||||
if (from == 'dl' && to == 'ml') return quantity * 100.0;
|
||||
if (from == 'dl' && to == 'cl') return quantity * 10.0;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Konverterar VERSALER-produktnamn till Title Case med smarta regler:
|
||||
/// - Token med `/` (förkortningar) lämnas i versaler: KY/KAL/LE/TO
|
||||
/// - Token som börjar med siffra (mängd/storlek) görs till gemener: 284g, 12x85g
|
||||
/// - Övriga token: första bokstav versal, resten gemen: Aprikosmarmelad
|
||||
String _normalizeProductName(String raw) {
|
||||
return raw.trim().split(' ').map((token) {
|
||||
if (token.isEmpty) return token;
|
||||
if (token.contains('/')) return token;
|
||||
if (RegExp(r'^\d').hasMatch(token)) return token.toLowerCase();
|
||||
return token[0].toUpperCase() + token.substring(1).toLowerCase();
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
// ── Redigeringstillstånd per rad ─────────────────────────────────────────────
|
||||
// ── Typ-alias ─────────────────────────────────────────────────────────────────
|
||||
|
||||
typedef _ItemEdit = ItemEdit;
|
||||
|
||||
// ── Redigeringsdialog ─────────────────────────────────────────────────────────
|
||||
|
||||
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 _ProductEntryMode? initialEntryMode;
|
||||
|
||||
const _EditDialog({
|
||||
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;
|
||||
_ProductEntryMode _entryMode = _ProductEntryMode.existing;
|
||||
bool _isCreatingProduct = false;
|
||||
// Lokal lista — utökas om nya produkter skapas under dialogen
|
||||
late List<ProductOption> _localProducts;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_productId = widget.current.productId;
|
||||
_productName = widget.current.productName == null
|
||||
? null
|
||||
: _normalizeProductName(widget.current.productName!);
|
||||
_destination = widget.current.destination;
|
||||
_entryMode = widget.initialEntryMode ??
|
||||
(_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing);
|
||||
_localProducts = List.of(widget.products);
|
||||
_productCategoryId = widget.current.categoryId ?? _categoryIdForProduct(_productId);
|
||||
_productCategoryPath = widget.current.categoryPath ?? _categoryPathForCategoryId(_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,
|
||||
);
|
||||
final initialPackQuantity = widget.current.packQuantity ?? inferred.packQuantity;
|
||||
final initialPackUnit = widget.current.packUnit ?? inferred.packUnit;
|
||||
final initialPackageCount = widget.current.packageCount ?? inferred.packageCount;
|
||||
|
||||
_quantityCtrl = TextEditingController(
|
||||
text: initialPackQuantity?.toString() ?? '',
|
||||
);
|
||||
_unitCtrl = TextEditingController(
|
||||
text: initialPackUnit ?? '',
|
||||
);
|
||||
_packageCountCtrl = TextEditingController(
|
||||
text: initialPackageCount.toString(),
|
||||
);
|
||||
_newProductNameCtrl = TextEditingController(
|
||||
text: _normalizeProductName(widget.current.productName ?? widget.item.rawName),
|
||||
);
|
||||
}
|
||||
|
||||
int? _categoryIdForProduct(int? productId) {
|
||||
if (productId == null) return null;
|
||||
return _localProducts
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == productId, orElse: () => null)
|
||||
?.categoryId;
|
||||
}
|
||||
|
||||
String? _categoryPathForCategoryId(int? categoryId) {
|
||||
if (categoryId == null) return null;
|
||||
|
||||
List<String>? walk(List<AdminCategoryNode> nodes, List<String> parents) {
|
||||
for (final node in nodes) {
|
||||
final path = [...parents, node.name];
|
||||
if (node.id == categoryId) return path;
|
||||
final found = walk(node.children, path);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return walk(widget.categoryTree, const [])?.join(' > ');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_quantityCtrl.dispose();
|
||||
_unitCtrl.dispose();
|
||||
_packageCountCtrl.dispose();
|
||||
_newProductNameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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) {
|
||||
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 = _categoryPathForCategoryId(_productCategoryId);
|
||||
_productCategorySource = CategorySelectionSource.manual;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyAiSuggestionForExistingSelection() {
|
||||
final suggestedId = widget.item.suggestedProductId;
|
||||
int? preselectedCategoryId = widget.item.categorySuggestionId;
|
||||
if (suggestedId != null) {
|
||||
setState(() {
|
||||
_productId = suggestedId;
|
||||
_productName = widget.item.suggestedProductName == null
|
||||
? null
|
||||
: _normalizeProductName(widget.item.suggestedProductName!);
|
||||
_productCategoryId = _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId;
|
||||
_productCategoryPath =
|
||||
_categoryPathForCategoryId(_productCategoryId) ?? widget.item.categorySuggestionPath;
|
||||
_productCategorySource = CategorySelectionSource.ai;
|
||||
});
|
||||
preselectedCategoryId = _productCategoryId;
|
||||
}
|
||||
|
||||
_openExistingCategoryPicker(
|
||||
preselectedCategoryId: preselectedCategoryId,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canConfirm {
|
||||
if (_isCreatingProduct) return false;
|
||||
if (_entryMode == _ProductEntryMode.create) return true;
|
||||
return _productId != null;
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
if (_entryMode == _ProductEntryMode.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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final item = widget.item;
|
||||
final aiCategory = item.categorySuggestionName;
|
||||
final aiPath = item.categorySuggestionPath;
|
||||
final aiLabel = (aiPath != null && aiPath.isNotEmpty)
|
||||
? aiPath
|
||||
: ((aiCategory != null && aiCategory.isNotEmpty) ? aiCategory : null);
|
||||
final suggestedProductLabel = item.suggestedProductName?.isNotEmpty == true
|
||||
? _normalizeProductName(item.suggestedProductName!)
|
||||
: (item.matchedProductName?.isNotEmpty == true
|
||||
? _normalizeProductName(item.matchedProductName!)
|
||||
: null);
|
||||
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 ?? widget.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: [
|
||||
// Destination
|
||||
SegmentedButton<_Destination>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _Destination.inventory,
|
||||
icon: Icon(Icons.kitchen_outlined, size: 16),
|
||||
label: Text('Inventarie'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Destination.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),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Produktval: befintlig produkt eller skapa ny från importnamnet
|
||||
SegmentedButton<_ProductEntryMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _ProductEntryMode.existing,
|
||||
icon: Icon(Icons.search, size: 16),
|
||||
label: Text('Befintlig'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _ProductEntryMode.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),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_entryMode == _ProductEntryMode.existing)
|
||||
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.textTheme.labelSmall,
|
||||
),
|
||||
backgroundColor: Colors.blue.shade50,
|
||||
side: BorderSide(color: Colors.blue.shade300),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: _applyAiSuggestionForExistingSelection,
|
||||
),
|
||||
),
|
||||
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 = id == null
|
||||
? null
|
||||
: _localProducts
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
_productName = selectedName == null
|
||||
? null
|
||||
: _normalizeProductName(selectedName);
|
||||
_productCategoryId = _categoryIdForProduct(id);
|
||||
_productCategoryPath = _categoryPathForCategoryId(_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.colorScheme.primary,
|
||||
),
|
||||
label: Text(
|
||||
'Kategori: $_productCategoryPath',
|
||||
style: theme.textTheme.labelSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
side: BorderSide(color: theme.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.textTheme.labelSmall,
|
||||
),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
side: BorderSide(color: Colors.green.shade300),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openExistingCategoryPicker(
|
||||
preselectedCategoryId: item.categorySuggestionId,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
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-forslag: $aiLabel',
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
side: BorderSide(color: Colors.green.shade300),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: item.categorySuggestionId == null
|
||||
? null
|
||||
: () => _openCreateCategoryPicker(
|
||||
preselectedCategoryId: item.categorySuggestionId,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_destination == _Destination.inventory) ...[
|
||||
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 x antal förpackningar).',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.green.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
] 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 == _ProductEntryMode.create ? 'Skapa och välj' : 'OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Huvudwidget ───────────────────────────────────────────────────────────────
|
||||
|
||||
class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||
@@ -828,6 +49,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
// Kategoriträdet för tvåstegs-picker
|
||||
List<AdminCategoryNode> _categoryTree = [];
|
||||
CategoryLookup _lookup = CategoryLookup([]);
|
||||
|
||||
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
||||
Map<int, InventoryItem> _inventoryByProduct = {};
|
||||
@@ -849,22 +71,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
?.categoryId;
|
||||
}
|
||||
|
||||
String? _categoryPathForCategoryId(int? categoryId) {
|
||||
if (categoryId == null) return null;
|
||||
|
||||
List<String>? walk(List<AdminCategoryNode> nodes, List<String> parents) {
|
||||
for (final node in nodes) {
|
||||
final path = [...parents, node.name];
|
||||
if (node.id == categoryId) return path;
|
||||
final found = walk(node.children, path);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return walk(_categoryTree, const [])?.join(' > ');
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
@@ -873,8 +79,16 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final results = await Future.wait([
|
||||
api.getJson(ProductApiPaths.list, token: token),
|
||||
api.getJson(ProductApiPaths.mine, token: token),
|
||||
adminRepo.listCategoryTree(),
|
||||
]);
|
||||
|
||||
List<AdminCategoryNode> categoryTree = _categoryTree;
|
||||
try {
|
||||
categoryTree = await adminRepo.listCategoryTree();
|
||||
} catch (e, st) {
|
||||
debugPrint('ReceiptImportTab._loadProducts categoryTree failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
|
||||
final globalData = results[0];
|
||||
final mineData = results[1];
|
||||
final globalList = globalData is List
|
||||
@@ -898,7 +112,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
setState(() {
|
||||
_products = dedupedById.values.toList();
|
||||
_categoryTree = results[2] as List<AdminCategoryNode>;
|
||||
_categoryTree = categoryTree;
|
||||
_lookup = CategoryLookup(categoryTree);
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
@@ -974,7 +189,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final pid = it.matchedProductId ?? it.suggestedProductId;
|
||||
notifier.setSelected(i, pid != null);
|
||||
if (pid != null) {
|
||||
final inferred = _inferPackageFields(
|
||||
final inferred = inferPackageFields(
|
||||
rawName: it.rawName,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
@@ -982,7 +197,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final name = it.matchedProductName ?? it.suggestedProductName;
|
||||
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
|
||||
final resolvedCategoryPath = it.categorySuggestionPath ??
|
||||
_categoryPathForCategoryId(resolvedCategoryId);
|
||||
_lookup.pathFor(resolvedCategoryId);
|
||||
notifier.setEdit(i, _ItemEdit(
|
||||
productId: pid,
|
||||
productName: name,
|
||||
@@ -1010,10 +225,22 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
Future<void> _openEditDialog(
|
||||
int index, {
|
||||
_ProductEntryMode? initialEntryMode,
|
||||
ImportProductEntryMode? initialEntryMode,
|
||||
}) async {
|
||||
if (_categoryTree.isEmpty) {
|
||||
await _loadProducts();
|
||||
if (!mounted) return;
|
||||
if (_categoryTree.isEmpty) {
|
||||
showGlobalErrorDialog(
|
||||
context,
|
||||
'Inga kategorier kunde laddas. Prova att uppdatera kategorier i Admin > Databas och försök igen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final item = _items![index];
|
||||
final inferred = _inferPackageFields(
|
||||
final inferred = inferPackageFields(
|
||||
rawName: item.rawName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
@@ -1025,7 +252,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
categoryId: item.categorySuggestionId ??
|
||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||
categoryPath: item.categorySuggestionPath ??
|
||||
_categoryPathForCategoryId(
|
||||
_lookup.pathFor(
|
||||
item.categorySuggestionId ??
|
||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||
),
|
||||
@@ -1041,7 +268,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
final result = await showDialog<_ItemEdit>(
|
||||
context: context,
|
||||
builder: (_) => _EditDialog(
|
||||
builder: (_) => EditDialog(
|
||||
item: item,
|
||||
current: current,
|
||||
products: _products,
|
||||
@@ -1134,7 +361,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
pantryAdded++;
|
||||
}
|
||||
} else {
|
||||
final inferred = _inferPackageFields(
|
||||
final inferred = inferPackageFields(
|
||||
rawName: item.rawName,
|
||||
quantity: edit.quantity ?? item.quantity,
|
||||
unit: edit.unit ?? item.unit,
|
||||
@@ -1149,7 +376,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final existing = _inventoryByProduct[pid];
|
||||
final qtyInExistingUnit = existing == null
|
||||
? null
|
||||
: _convertQuantity(qty, unit, existing.unit);
|
||||
: convertQuantity(qty, unit, existing.unit);
|
||||
if (existing != null && qtyInExistingUnit != null) {
|
||||
await invRepo.updateInventoryItem(
|
||||
existing.id,
|
||||
@@ -1326,7 +553,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
||||
? _inventoryByProduct[edit!.productId]
|
||||
: null;
|
||||
final inferredForPreview = _inferPackageFields(
|
||||
final inferredForPreview = inferPackageFields(
|
||||
rawName: item.rawName,
|
||||
quantity: edit?.quantity ?? item.quantity,
|
||||
unit: edit?.unit ?? item.unit,
|
||||
@@ -1343,7 +570,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
'st';
|
||||
final convertedPreviewQty = existingInv == null
|
||||
? null
|
||||
: _convertQuantity(
|
||||
: convertQuantity(
|
||||
previewIncomingQty,
|
||||
previewIncomingUnit,
|
||||
existingInv.unit,
|
||||
@@ -1364,7 +591,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
_normalizeProductName(item.rawName),
|
||||
normalizeProductName(item.rawName),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
subtitle: Column(
|
||||
@@ -1388,7 +615,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Produktnamn: ${_normalizeProductName(edit!.productName ?? '')}',
|
||||
'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -1420,7 +647,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
],
|
||||
)
|
||||
else if (isSuggested)
|
||||
Text('Namnförslag: ${_normalizeProductName(item.suggestedProductName ?? '')}',
|
||||
Text('Namnförslag: ${normalizeProductName(item.suggestedProductName ?? '')}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
||||
else
|
||||
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
|
||||
@@ -1443,7 +670,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openEditDialog(
|
||||
i,
|
||||
initialEntryMode: _ProductEntryMode.existing,
|
||||
initialEntryMode: ImportProductEntryMode.existing,
|
||||
),
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
label: const Text('Välj befintlig'),
|
||||
@@ -1455,7 +682,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openEditDialog(
|
||||
i,
|
||||
initialEntryMode: _ProductEntryMode.create,
|
||||
initialEntryMode: ImportProductEntryMode.create,
|
||||
),
|
||||
icon: const Icon(Icons.add_box_outlined, size: 16),
|
||||
label: const Text('Ny produkt'),
|
||||
|
||||
Reference in New Issue
Block a user