feat: implement receipt import functionality with editing capabilities and product selection

This commit is contained in:
Nils-Johan Gynther
2026-05-01 01:32:30 +02:00
parent b31862d1ff
commit d4b35f4a5b
@@ -2,11 +2,595 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 '../../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 '../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 {
const ReceiptImportTab({super.key});