feat: add receipt import session management with file handling and item editing support

This commit is contained in:
Nils-Johan Gynther
2026-05-01 08:57:34 +02:00
parent f983458ff0
commit 5c263a14df
3 changed files with 212 additions and 54 deletions
@@ -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<ParsedReceiptItem>? items; // null = ej parsad än
final Map<int, ItemEdit> edits;
final Map<int, bool> selected;
const ReceiptImportSession({
this.fileBytes,
this.fileExtension,
this.items,
this.edits = const {},
this.selected = const {},
});
ReceiptImportSession copyWith({
Uint8List? fileBytes,
String? fileExtension,
List<ParsedReceiptItem>? items,
Map<int, ItemEdit>? edits,
Map<int, bool>? 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<ReceiptImportSession?> {
@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<ParsedReceiptItem> 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<int, ItemEdit>.from(state!.edits)..[index] = edit;
state = state!.copyWith(edits: edits);
}
void setSelected(int index, bool value) {
if (state == null) return;
final selected = Map<int, bool>.from(state!.selected)..[index] = value;
state = state!.copyWith(selected: selected);
}
void clear() => state = null;
}
final receiptImportSessionProvider =
NotifierProvider<ReceiptImportSessionNotifier, ReceiptImportSession?>(
ReceiptImportSessionNotifier.new,
);
@@ -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<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 = {};
// Session-state lyfts till provider — överlever tabbyte
ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider);
List<ParsedReceiptItem>? get _items => _session?.items;
Map<int, _ItemEdit> get _edits => _session?.edits ?? {};
Map<int, bool> get _selected => _session?.selected ?? {};
// Produktlistan för pickern
List<ProductOption> _products = [];
@@ -382,17 +374,19 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<void> _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<ReceiptImportTab> {
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<ReceiptImportTab> {
),
);
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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(