feat: implement receipt import functionality with editing capabilities and product selection
This commit is contained in:
@@ -2,11 +2,595 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
|
import '../../../core/api/api_paths.dart';
|
||||||
|
import '../../../core/api/api_providers.dart';
|
||||||
|
import '../../../core/ui/product_picker_field.dart';
|
||||||
import '../../../core/utils/global_error_handler.dart';
|
import '../../../core/utils/global_error_handler.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
|
import '../../inventory/data/inventory_providers.dart';
|
||||||
|
import '../../inventory/domain/inventory_item.dart';
|
||||||
|
import '../../pantry/data/pantry_providers.dart';
|
||||||
|
import '../../pantry/domain/pantry_item.dart';
|
||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
import '../domain/parsed_receipt_item.dart';
|
import '../domain/parsed_receipt_item.dart';
|
||||||
|
|
||||||
|
enum _Destination { inventory, pantry }
|
||||||
|
|
||||||
|
// ── Redigeringstillstånd per rad ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ItemEdit {
|
||||||
|
final int? productId;
|
||||||
|
final String? productName;
|
||||||
|
final double? quantity;
|
||||||
|
final String? unit;
|
||||||
|
final _Destination destination;
|
||||||
|
|
||||||
|
const _ItemEdit({
|
||||||
|
this.productId,
|
||||||
|
this.productName,
|
||||||
|
this.quantity,
|
||||||
|
this.unit,
|
||||||
|
this.destination = _Destination.inventory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Redigeringsdialog ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _EditDialog extends StatefulWidget {
|
||||||
|
final ParsedReceiptItem item;
|
||||||
|
final _ItemEdit current;
|
||||||
|
final List<ProductOption> products;
|
||||||
|
|
||||||
|
const _EditDialog({
|
||||||
|
required this.item,
|
||||||
|
required this.current,
|
||||||
|
required this.products,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EditDialog> createState() => _EditDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditDialogState extends State<_EditDialog> {
|
||||||
|
late final TextEditingController _quantityCtrl;
|
||||||
|
late final TextEditingController _unitCtrl;
|
||||||
|
int? _productId;
|
||||||
|
String? _productName;
|
||||||
|
_Destination _destination = _Destination.inventory;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_productId = widget.current.productId;
|
||||||
|
_productName = widget.current.productName;
|
||||||
|
_destination = widget.current.destination;
|
||||||
|
_quantityCtrl = TextEditingController(
|
||||||
|
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
|
||||||
|
);
|
||||||
|
_unitCtrl = TextEditingController(
|
||||||
|
text: widget.current.unit ?? widget.item.unit ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_quantityCtrl.dispose();
|
||||||
|
_unitCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final aiCategory = widget.item.categorySuggestionName;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// AI-kategorisuggestion
|
||||||
|
if (aiCategory != null) ...[
|
||||||
|
Wrap(
|
||||||
|
children: [
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.auto_awesome, size: 14),
|
||||||
|
label: Text('AI: $aiCategory',
|
||||||
|
style: theme.textTheme.labelSmall),
|
||||||
|
backgroundColor: Colors.green.shade50,
|
||||||
|
side: BorderSide(color: Colors.green.shade300),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
// 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),
|
||||||
|
ProductPickerField(
|
||||||
|
products: widget.products,
|
||||||
|
value: _productId,
|
||||||
|
label: 'Produkt',
|
||||||
|
onChanged: (id) {
|
||||||
|
setState(() {
|
||||||
|
_productId = id;
|
||||||
|
_productName = id == null
|
||||||
|
? null
|
||||||
|
: widget.products
|
||||||
|
.cast<ProductOption?>()
|
||||||
|
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||||
|
?.name;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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: 'Antal', border: OutlineInputBorder()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _unitCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Enhet', border: OutlineInputBorder()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
] 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: _productId == null ? null : () {
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
_ItemEdit(
|
||||||
|
productId: _productId,
|
||||||
|
productName: _productName,
|
||||||
|
quantity: double.tryParse(_quantityCtrl.text) ?? widget.item.quantity,
|
||||||
|
unit: _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim(),
|
||||||
|
destination: _destination,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Huvudwidget ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||||
|
const ReceiptImportTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ReceiptImportTab> createState() => _ReceiptImportTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSaving = false;
|
||||||
|
PlatformFile? _pickedFile;
|
||||||
|
List<ParsedReceiptItem>? _items;
|
||||||
|
|
||||||
|
// Checkbox-state och per-rad redigering
|
||||||
|
final Map<int, bool> _selected = {};
|
||||||
|
final Map<int, _ItemEdit> _edits = {};
|
||||||
|
|
||||||
|
// Produktlistan för pickern
|
||||||
|
List<ProductOption> _products = [];
|
||||||
|
bool _loadingProducts = false;
|
||||||
|
|
||||||
|
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
||||||
|
Map<int, InventoryItem> _inventoryByProduct = {};
|
||||||
|
|
||||||
|
// Befintligt baslager: productId → PantryItem (för deduplicering)
|
||||||
|
Set<int> _pantryProductIds = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProducts() async {
|
||||||
|
setState(() => _loadingProducts = true);
|
||||||
|
try {
|
||||||
|
final token = await ref.read(authStateProvider.future);
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final data = await api.getJson(ProductApiPaths.list, token: token);
|
||||||
|
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_products = list
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map((e) => (id: e['id'] as int, name: e['name'] as String))
|
||||||
|
.toList();
|
||||||
|
_loadingProducts = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _loadingProducts = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInventory() async {
|
||||||
|
try {
|
||||||
|
final token = await ref.read(authStateProvider.future);
|
||||||
|
final invRepo = ref.read(inventoryRepositoryProvider);
|
||||||
|
final pantryRepo = ref.read(pantryRepositoryProvider);
|
||||||
|
final results = await Future.wait([
|
||||||
|
invRepo.fetchInventory(token: token),
|
||||||
|
pantryRepo.fetchPantry(token: token),
|
||||||
|
]);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_inventoryByProduct = {
|
||||||
|
for (final item in results[0] as List<InventoryItem>) item.productId: item,
|
||||||
|
};
|
||||||
|
_pantryProductIds = {
|
||||||
|
for (final item in results[1] as List<PantryItem>) item.productId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Tyst fel — sammanslagningsfunktionen är valfri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
final result = await FilePicker.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_pickedFile = result.files.first;
|
||||||
|
_items = null;
|
||||||
|
_selected.clear();
|
||||||
|
_edits.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_pickedFile == null) return;
|
||||||
|
setState(() { _isLoading = true; _items = null; _selected.clear(); _edits.clear(); });
|
||||||
|
|
||||||
|
try {
|
||||||
|
final token = await ref.read(authStateProvider.future);
|
||||||
|
final repo = ref.read(importRepositoryProvider);
|
||||||
|
final items = await repo.importReceiptFile(
|
||||||
|
bytes: _pickedFile!.bytes!,
|
||||||
|
filename: _pickedFile!.name,
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = items;
|
||||||
|
// Förmarkera rader som har en träff
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
final it = items[i];
|
||||||
|
final pid = it.matchedProductId ?? it.suggestedProductId;
|
||||||
|
_selected[i] = pid != null;
|
||||||
|
if (pid != null) {
|
||||||
|
final name = it.matchedProductName ?? it.suggestedProductName;
|
||||||
|
_edits[i] = _ItemEdit(
|
||||||
|
productId: pid,
|
||||||
|
productName: name,
|
||||||
|
quantity: it.quantity,
|
||||||
|
unit: it.unit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Ladda inventariet för att visa befintliga poster och möjliggöra sammanslagning
|
||||||
|
await _loadInventory();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEditDialog(int index) async {
|
||||||
|
final item = _items![index];
|
||||||
|
final current = _edits[index] ??
|
||||||
|
_ItemEdit(
|
||||||
|
productId: item.matchedProductId ?? item.suggestedProductId,
|
||||||
|
productName: item.matchedProductName ?? item.suggestedProductName,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await showDialog<_ItemEdit>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _EditDialog(item: item, current: current, products: _products),
|
||||||
|
);
|
||||||
|
if (result != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_edits[index] = result;
|
||||||
|
_selected[index] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addSelected() async {
|
||||||
|
final items = _items;
|
||||||
|
if (items == null) return;
|
||||||
|
|
||||||
|
final toAdd = <int>[];
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (_selected[i] == true && _edits[i]?.productId != null) toAdd.add(i);
|
||||||
|
}
|
||||||
|
if (toAdd.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Välj produkter för alla markerade rader först.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
int created = 0;
|
||||||
|
int merged = 0;
|
||||||
|
int pantryAdded = 0;
|
||||||
|
int pantrySkipped = 0;
|
||||||
|
try {
|
||||||
|
final token = await ref.read(authStateProvider.future);
|
||||||
|
final invRepo = ref.read(inventoryRepositoryProvider);
|
||||||
|
final pantryRepo = ref.read(pantryRepositoryProvider);
|
||||||
|
|
||||||
|
for (final i in toAdd) {
|
||||||
|
final edit = _edits[i]!;
|
||||||
|
final item = items[i];
|
||||||
|
final pid = edit.productId!;
|
||||||
|
|
||||||
|
if (edit.destination == _Destination.pantry) {
|
||||||
|
if (_pantryProductIds.contains(pid)) {
|
||||||
|
pantrySkipped++;
|
||||||
|
} else {
|
||||||
|
await pantryRepo.createPantryItem(pid, token: token);
|
||||||
|
pantryAdded++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final qty = edit.quantity ?? item.quantity ?? 1.0;
|
||||||
|
final unit = edit.unit ?? item.unit ?? 'st';
|
||||||
|
final existing = _inventoryByProduct[pid];
|
||||||
|
if (existing != null) {
|
||||||
|
await invRepo.updateInventoryItem(
|
||||||
|
existing.id,
|
||||||
|
{'quantity': existing.quantity + qty},
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
merged++;
|
||||||
|
} else {
|
||||||
|
await invRepo.createInventoryItem({
|
||||||
|
'productId': pid,
|
||||||
|
'quantity': qty,
|
||||||
|
'unit': unit,
|
||||||
|
if (item.brand != null) 'brand': item.brand,
|
||||||
|
}, token: token);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final parts = <String>[
|
||||||
|
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
||||||
|
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
|
||||||
|
if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager',
|
||||||
|
if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager',
|
||||||
|
];
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(parts.join(', ') + '.')),
|
||||||
|
);
|
||||||
|
// Avmarkera sparade rader och uppdatera inventariet
|
||||||
|
setState(() {
|
||||||
|
for (final i in toAdd) _selected[i] = false;
|
||||||
|
});
|
||||||
|
await _loadInventory();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showGlobalErrorDialog(context, 'Fel vid sparande: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSaving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canSubmit => !_isLoading && _pickedFile?.bytes != null;
|
||||||
|
int get _selectedCount => _selected.values.where((v) => v).length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final items = _items;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _pickFile,
|
||||||
|
icon: const Icon(Icons.attach_file),
|
||||||
|
label: Text(_pickedFile == null ? 'Välj kvittofil' : _pickedFile!.name),
|
||||||
|
),
|
||||||
|
if (_pickedFile != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${(_pickedFile!.size / 1024).round()} KB',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_isLoading) ...[
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Tolkar kvittot — detta kan ta upp till en minut...',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _canSubmit ? _submit : null,
|
||||||
|
icon: const Icon(Icons.receipt_long_outlined),
|
||||||
|
label: const Text('Importera kvitto'),
|
||||||
|
),
|
||||||
|
// ── Resultatlista ──────────────────────────────────────────────
|
||||||
|
if (items != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
for (var i = 0; i < items.length; i++) _selected[i] = _selectedCount < items.length;
|
||||||
|
}),
|
||||||
|
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...List.generate(items.length, (i) {
|
||||||
|
final item = items[i];
|
||||||
|
final edit = _edits[i];
|
||||||
|
final isChecked = _selected[i] ?? false;
|
||||||
|
final hasProduct = edit?.productId != null;
|
||||||
|
final isMatched = item.matchedProductId != null;
|
||||||
|
final isSuggested = item.suggestedProductId != null && item.matchedProductId == null;
|
||||||
|
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
||||||
|
? _inventoryByProduct[edit!.productId]
|
||||||
|
: null;
|
||||||
|
final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry
|
||||||
|
? _pantryProductIds.contains(edit!.productId)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Checkbox(
|
||||||
|
value: isChecked,
|
||||||
|
onChanged: (v) => setState(() => _selected[i] = v ?? false),
|
||||||
|
),
|
||||||
|
title: Text(item.rawName, style: theme.textTheme.bodyMedium),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
[
|
||||||
|
if ((edit?.quantity ?? item.quantity) != null)
|
||||||
|
'${edit?.quantity ?? item.quantity}',
|
||||||
|
if ((edit?.unit ?? item.unit) != null)
|
||||||
|
edit?.unit ?? item.unit!,
|
||||||
|
if (item.price != null) '· ${item.price} kr',
|
||||||
|
].join(' '),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
if (hasProduct)
|
||||||
|
Text(edit!.productName ?? '', style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
))
|
||||||
|
else if (isSuggested)
|
||||||
|
Text('Förslag: ${item.suggestedProductName ?? ''}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
||||||
|
else
|
||||||
|
Text('Ingen match — tryck för att välja produkt',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)),
|
||||||
|
if (existingInv != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (edit?.quantity ?? item.quantity ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
if (alreadyInPantry) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.inventory_2_outlined, size: 12, color: Colors.orange.shade700),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text('Finns redan i baslager',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Icon(
|
||||||
|
hasProduct ? Icons.check_circle : (isSuggested ? Icons.help_outline : Icons.error_outline),
|
||||||
|
color: hasProduct
|
||||||
|
? Colors.green
|
||||||
|
: (isSuggested ? Colors.orange : theme.colorScheme.error),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onTap: () => _openEditDialog(i),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: (_selectedCount > 0 && !_isSaving) ? _addSelected : null,
|
||||||
|
icon: _isSaving
|
||||||
|
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Icon(Icons.add_shopping_cart),
|
||||||
|
label: Text(_selectedCount > 0 ? 'Lägg till $_selectedCount markerade' : 'Markera rader att lägga till'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ReceiptImportTab extends ConsumerStatefulWidget {
|
class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||||
const ReceiptImportTab({super.key});
|
const ReceiptImportTab({super.key});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user