diff --git a/flutter/lib/core/ui/category_then_product_picker.dart b/flutter/lib/core/ui/category_then_product_picker.dart index 5e5a9700..59bd4449 100644 --- a/flutter/lib/core/ui/category_then_product_picker.dart +++ b/flutter/lib/core/ui/category_then_product_picker.dart @@ -47,6 +47,7 @@ class CategoryThenProductPicker { required List products, int? currentProductId, int? preselectedCategoryId, + String? initialQuery, Future Function(String name, int categoryId)? onCreate, }) async { AdminCategoryNode? selectedCategory; @@ -94,6 +95,7 @@ class CategoryThenProductPicker { value: currentProductId, label: 'Produkt i "${selectedCategory!.name}"', categoryFilter: null, // redan förfiltrerat + initialQuery: initialQuery, onCreate: onCreateBound, ); } diff --git a/flutter/lib/features/import/data/receipt_import_session.dart b/flutter/lib/features/import/data/receipt_import_session.dart new file mode 100644 index 00000000..5550652b --- /dev/null +++ b/flutter/lib/features/import/data/receipt_import_session.dart @@ -0,0 +1,95 @@ +import 'dart:typed_data'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../domain/parsed_receipt_item.dart'; + +// ── Destination-enum ────────────────────────────────────────────────────────── + +enum ImportDestination { inventory, pantry } + +// ── Per-rad redigeringstillstånd ────────────────────────────────────────────── + +class ItemEdit { + final int? productId; + final String? productName; + final double? quantity; + final String? unit; + final ImportDestination destination; + + const ItemEdit({ + this.productId, + this.productName, + this.quantity, + this.unit, + this.destination = ImportDestination.inventory, + }); +} + +// ── Session-state ───────────────────────────────────────────────────────────── + +class ReceiptImportSession { + final Uint8List? fileBytes; + final String? fileExtension; + final List? items; // null = ej parsad än + final Map edits; + final Map selected; + + const ReceiptImportSession({ + this.fileBytes, + this.fileExtension, + this.items, + this.edits = const {}, + this.selected = const {}, + }); + + ReceiptImportSession copyWith({ + Uint8List? fileBytes, + String? fileExtension, + List? items, + Map? edits, + Map? selected, + }) => + ReceiptImportSession( + fileBytes: fileBytes ?? this.fileBytes, + fileExtension: fileExtension ?? this.fileExtension, + items: items ?? this.items, + edits: edits ?? this.edits, + selected: selected ?? this.selected, + ); +} + +// ── Notifier ────────────────────────────────────────────────────────────────── + +class ReceiptImportSessionNotifier + extends Notifier { + @override + ReceiptImportSession? build() => null; + + /// Ny fil vald — återställer items/edits/selected, behåller ingenting gammalt. + void setFile(Uint8List bytes, String extension) { + state = ReceiptImportSession(fileBytes: bytes, fileExtension: extension); + } + + void setItems(List items) { + // Bevara filinformationen när items sätts + state = (state ?? const ReceiptImportSession()).copyWith(items: items); + } + + void setEdit(int index, ItemEdit edit) { + if (state == null) return; + final edits = Map.from(state!.edits)..[index] = edit; + state = state!.copyWith(edits: edits); + } + + void setSelected(int index, bool value) { + if (state == null) return; + final selected = Map.from(state!.selected)..[index] = value; + state = state!.copyWith(selected: selected); + } + + void clear() => state = null; +} + +final receiptImportSessionProvider = + NotifierProvider( + ReceiptImportSessionNotifier.new, +); diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 2dc22ab2..5916ef2f 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -15,27 +15,17 @@ 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/receipt_import_session.dart'; import '../domain/parsed_receipt_item.dart'; -enum _Destination { inventory, pantry } +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html show Blob, Url, window; + +typedef _Destination = ImportDestination; // ── 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, - }); -} +typedef _ItemEdit = ItemEdit; // ── Redigeringsdialog ───────────────────────────────────────────────────────── @@ -118,6 +108,7 @@ class _EditDialogState extends State<_EditDialog> { products: _localProducts, currentProductId: _productId, preselectedCategoryId: preselectedCategoryId, + initialQuery: item.rawName, onCreate: onCreateWrapped, ); if (id != null && mounted) { @@ -288,11 +279,12 @@ 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 = {}; + // Session-state lyfts till provider — överlever tabbyte + ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider); + List? get _items => _session?.items; + Map get _edits => _session?.edits ?? {}; + Map get _selected => _session?.selected ?? {}; // Produktlistan för pickern List _products = []; @@ -382,17 +374,19 @@ class _ReceiptImportTabState extends ConsumerState { withData: true, ); if (result == null || result.files.isEmpty) return; - setState(() { - _pickedFile = result.files.first; - _items = null; - _selected.clear(); - _edits.clear(); - }); + final file = result.files.first; + setState(() => _pickedFile = file); + // Spara bildbytes i session så att förhandsvisningen överlever tabbyte + ref.read(receiptImportSessionProvider.notifier).setFile( + file.bytes!, + file.extension?.toLowerCase() ?? '', + ); } Future _submit() async { if (_pickedFile == null) return; - setState(() { _isLoading = true; _items = null; _selected.clear(); _edits.clear(); }); + setState(() { _isLoading = true; }); + // Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här try { final token = await ref.read(authStateProvider.future); @@ -403,24 +397,23 @@ class _ReceiptImportTabState extends ConsumerState { 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, - ); - } + final notifier = ref.read(receiptImportSessionProvider.notifier); + notifier.setItems(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; + notifier.setSelected(i, pid != null); + if (pid != null) { + final name = it.matchedProductName ?? it.suggestedProductName; + notifier.setEdit(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) { @@ -473,10 +466,9 @@ class _ReceiptImportTabState extends ConsumerState { ), ); if (result != null && mounted) { - setState(() { - _edits[index] = result; - _selected[index] = true; - }); + ref.read(receiptImportSessionProvider.notifier).setEdit(index, result); + ref.read(receiptImportSessionProvider.notifier).setSelected(index, true); + setState(() {}); } } @@ -551,9 +543,9 @@ class _ReceiptImportTabState extends ConsumerState { SnackBar(content: Text(parts.join(', ') + '.')), ); // Avmarkera sparade rader och uppdatera inventariet - setState(() { - for (final i in toAdd) _selected[i] = false; - }); + final notifier = ref.read(receiptImportSessionProvider.notifier); + for (final i in toAdd) notifier.setSelected(i, false); + setState(() {}); await _loadInventory(); } catch (e) { if (mounted) showGlobalErrorDialog(context, 'Fel vid sparande: $e'); @@ -565,10 +557,69 @@ class _ReceiptImportTabState extends ConsumerState { bool get _canSubmit => !_isLoading && _pickedFile?.bytes != null; int get _selectedCount => _selected.values.where((v) => v).length; + // ── Kvittobild / PDF-förhandsvisning ─────────────────────────────────────── + + Widget _buildReceiptPreview(ThemeData theme, ReceiptImportSession session) { + final bytes = session.fileBytes!; + final ext = session.fileExtension ?? ''; + final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext); + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + dense: true, + leading: Icon( + isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined, + color: theme.colorScheme.primary, + ), + title: const Text('Kvittoförhandsvisning'), + trailing: isImage + ? null + : OutlinedButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('Öppna PDF'), + style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () { + final blob = html.Blob([bytes], 'application/pdf'); + final url = html.Url.createObjectUrlFromBlob(blob); + html.window.open(url, '_blank'); + }, + ), + ), + if (isImage) + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 600), + child: Image.memory(bytes, fit: BoxFit.contain), + ), + ), + ) + else + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + 'PDF-förhandsvisning stöds inte i appen — se importerade rader nedan.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { + final session = ref.watch(receiptImportSessionProvider); final theme = Theme.of(context); - final items = _items; + final items = session?.items; return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -592,6 +643,10 @@ class _ReceiptImportTabState extends ConsumerState { style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), ), ], + // ── Förhandsvisning av kvitto ──────────────────────────────────────── + if (session?.fileBytes != null) ...[ const SizedBox(height: 12), + _buildReceiptPreview(theme, session!), + ], const SizedBox(height: 24), if (_isLoading) ...[ const LinearProgressIndicator(), @@ -617,7 +672,10 @@ class _ReceiptImportTabState extends ConsumerState { 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; + final notifier = ref.read(receiptImportSessionProvider.notifier); + for (var i = 0; i < items.length; i++) { + notifier.setSelected(i, _selectedCount < items.length); + } }), child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), ), @@ -643,7 +701,10 @@ class _ReceiptImportTabState extends ConsumerState { child: ListTile( leading: Checkbox( value: isChecked, - onChanged: (v) => setState(() => _selected[i] = v ?? false), + onChanged: (v) { + ref.read(receiptImportSessionProvider.notifier).setSelected(i, v ?? false); + setState(() {}); + }, ), title: Text(item.rawName, style: theme.textTheme.bodyMedium), subtitle: Column(