diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index f5e5e3f3..ff2f54bc 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -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 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() + .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 createState() => _ReceiptImportTabState(); +} + +class _ReceiptImportTabState extends ConsumerState { + bool _isLoading = false; + bool _isSaving = false; + PlatformFile? _pickedFile; + List? _items; + + // Checkbox-state och per-rad redigering + final Map _selected = {}; + final Map _edits = {}; + + // Produktlistan för pickern + List _products = []; + bool _loadingProducts = false; + + // Befintligt inventarie: productId → InventoryItem (för sammanslagning) + Map _inventoryByProduct = {}; + + // Befintligt baslager: productId → PantryItem (för deduplicering) + Set _pantryProductIds = {}; + + @override + void initState() { + super.initState(); + _loadProducts(); + } + + Future _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?)?['items'] as List? ?? []); + if (mounted) { + setState(() { + _products = list + .cast>() + .map((e) => (id: e['id'] as int, name: e['name'] as String)) + .toList(); + _loadingProducts = false; + }); + } + } catch (_) { + if (mounted) setState(() => _loadingProducts = false); + } + } + + Future _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) item.productId: item, + }; + _pantryProductIds = { + for (final item in results[1] as List) item.productId, + }; + }); + } + } catch (_) { + // Tyst fel — sammanslagningsfunktionen är valfri + } + } + + Future _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 _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 _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 _addSelected() async { + final items = _items; + if (items == null) return; + + final toAdd = []; + 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 = [ + 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});