Files
recipe-app/flutter/lib/features/import/presentation/edit_dialog.dart
T
Nils-Johan Gynther 7d63b615b6
Test Suite / test (24.15.0) (push) Has been cancelled
feat: add unit mapping functionality and confirmation dialog for unit changes in import process
2026-05-07 08:10:56 +02:00

695 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../../../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 {
final originalUnit = widget.current.unit ?? widget.item.unit;
final newUnit = _unitCtrl.text.trim().isEmpty ? originalUnit : _unitCtrl.text.trim();
await _confirmUnitChange(originalUnit!, newUnit);
if (_entryMode == ImportProductEntryMode.create) {
final trimmedName = _newProductNameCtrl.text.trim();
if (trimmedName.isEmpty) {
showGlobalErrorDialog(context, 'Ange ett produktnamn först.');
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,
),
);
}
Future<void> _confirmUnitChange(String originalUnit, String newUnit) async {
if (originalUnit == newUnit) return;
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Bekräfta enhetsändring'),
content: Text(
'Du försöker ändra enheten från "$originalUnit" till "$newUnit". Vill du fortsätta med denna ändring?',
),
actions: <Widget>[
TextButton(
child: const Text('Avbryt'),
onPressed: () {
_unitCtrl.text = originalUnit;
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('Bekräfta'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
// ── 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;
}
}