695 lines
26 KiB
Dart
695 lines
26 KiB
Dart
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;
|
||
}
|
||
}
|