feat(receipt-import): enhance bread category detection and improve session management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-03 16:34:15 +02:00
parent a1c4a2f24d
commit fa7f225ee5
7 changed files with 299 additions and 12 deletions
@@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'dart:convert';
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/parsed_receipt_item.dart';
// ── Destination-enum ──────────────────────────────────────────────────────────
@@ -43,6 +46,7 @@ class ItemEdit {
class ReceiptImportSession {
final Uint8List? fileBytes;
final String? fileExtension;
final String? fileName;
final List<ParsedReceiptItem>? items; // null = ej parsad än
final Map<int, ItemEdit> edits;
final Map<int, bool> selected;
@@ -50,6 +54,7 @@ class ReceiptImportSession {
const ReceiptImportSession({
this.fileBytes,
this.fileExtension,
this.fileName,
this.items,
this.edits = const {},
this.selected = const {},
@@ -58,6 +63,7 @@ class ReceiptImportSession {
ReceiptImportSession copyWith({
Uint8List? fileBytes,
String? fileExtension,
String? fileName,
List<ParsedReceiptItem>? items,
Map<int, ItemEdit>? edits,
Map<int, bool>? selected,
@@ -65,42 +71,168 @@ class ReceiptImportSession {
ReceiptImportSession(
fileBytes: fileBytes ?? this.fileBytes,
fileExtension: fileExtension ?? this.fileExtension,
fileName: fileName ?? this.fileName,
items: items ?? this.items,
edits: edits ?? this.edits,
selected: selected ?? this.selected,
);
Map<String, dynamic> toJson() => {
'fileBytes': fileBytes == null ? null : base64Encode(fileBytes!),
'fileExtension': fileExtension,
'fileName': fileName,
'items': items?.map((e) => e.toJson()).toList(),
'edits': edits.map((key, value) => MapEntry(key.toString(), {
'productId': value.productId,
'productName': value.productName,
'categoryId': value.categoryId,
'categoryPath': value.categoryPath,
'categorySource': value.categorySource?.name,
'quantity': value.quantity,
'unit': value.unit,
'packQuantity': value.packQuantity,
'packUnit': value.packUnit,
'packageCount': value.packageCount,
'destination': value.destination.name,
})),
'selected': selected.map((key, value) => MapEntry(key.toString(), value)),
};
factory ReceiptImportSession.fromJson(Map<String, dynamic> json) {
final rawItems = json['items'] as List<dynamic>?;
final items = rawItems
?.whereType<Map<String, dynamic>>()
.map(ParsedReceiptItem.fromJson)
.toList();
final rawEdits = (json['edits'] as Map<String, dynamic>? ?? {});
final edits = <int, ItemEdit>{};
for (final entry in rawEdits.entries) {
final idx = int.tryParse(entry.key);
final value = entry.value;
if (idx == null || value is! Map<String, dynamic>) continue;
edits[idx] = ItemEdit(
productId: (value['productId'] as num?)?.toInt(),
productName: value['productName'] as String?,
categoryId: (value['categoryId'] as num?)?.toInt(),
categoryPath: value['categoryPath'] as String?,
categorySource: switch (value['categorySource']) {
'ai' => CategorySelectionSource.ai,
'manual' => CategorySelectionSource.manual,
_ => null,
},
quantity: (value['quantity'] as num?)?.toDouble(),
unit: value['unit'] as String?,
packQuantity: (value['packQuantity'] as num?)?.toDouble(),
packUnit: value['packUnit'] as String?,
packageCount: (value['packageCount'] as num?)?.toDouble(),
destination: (value['destination'] as String?) == ImportDestination.pantry.name
? ImportDestination.pantry
: ImportDestination.inventory,
);
}
final rawSelected = (json['selected'] as Map<String, dynamic>? ?? {});
final selected = <int, bool>{};
for (final entry in rawSelected.entries) {
final idx = int.tryParse(entry.key);
if (idx == null) continue;
selected[idx] = entry.value == true;
}
Uint8List? fileBytes;
final fileBytesRaw = json['fileBytes'];
if (fileBytesRaw is String && fileBytesRaw.isNotEmpty) {
try {
fileBytes = base64Decode(fileBytesRaw);
} catch (_) {
fileBytes = null;
}
}
return ReceiptImportSession(
fileBytes: fileBytes,
fileExtension: json['fileExtension'] as String?,
fileName: json['fileName'] as String?,
items: items,
edits: edits,
selected: selected,
);
}
}
// ── Notifier ──────────────────────────────────────────────────────────────────
class ReceiptImportSessionNotifier
extends Notifier<ReceiptImportSession?> {
static const _storageKey = 'receipt_import_session_v1';
@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 setFile(Uint8List bytes, String extension, {String? fileName}) {
state = ReceiptImportSession(
fileBytes: bytes,
fileExtension: extension,
fileName: fileName,
);
unawaited(_persist());
}
void setItems(List<ParsedReceiptItem> items) {
// Bevara filinformationen när items sätts
state = (state ?? const ReceiptImportSession()).copyWith(items: items);
unawaited(_persist());
}
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);
unawaited(_persist());
}
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);
unawaited(_persist());
}
void clear() => state = null;
Future<void> restore() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) return;
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
state = ReceiptImportSession.fromJson(decoded);
}
} catch (_) {
await prefs.remove(_storageKey);
}
}
void clear() {
state = null;
unawaited(_removePersisted());
}
Future<void> _persist() async {
final prefs = await SharedPreferences.getInstance();
if (state == null) {
await prefs.remove(_storageKey);
return;
}
await prefs.setString(_storageKey, jsonEncode(state!.toJson()));
}
Future<void> _removePersisted() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey);
}
}
final receiptImportSessionProvider =
@@ -51,4 +51,27 @@ class ParsedReceiptItem {
categorySuggestionId: (cat?['categoryId'] as num?)?.toInt(),
);
}
Map<String, dynamic> toJson() {
return {
'rawName': rawName,
'quantity': quantity,
'unit': unit,
'price': price,
'brand': brand,
'origin': origin,
'matchedProductId': matchedProductId,
'matchedProductName': matchedProductName,
'suggestedProductId': suggestedProductId,
'suggestedProductName': suggestedProductName,
if (categorySuggestionId != null ||
categorySuggestionName != null ||
categorySuggestionPath != null)
'categorySuggestion': {
'categoryId': categorySuggestionId,
'categoryName': categorySuggestionName,
'path': categorySuggestionPath,
},
};
}
}
@@ -66,6 +66,30 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
void initState() {
super.initState();
_loadProducts();
_restoreSession();
}
Future<void> _restoreSession() async {
final notifier = ref.read(receiptImportSessionProvider.notifier);
await notifier.restore();
final session = ref.read(receiptImportSessionProvider);
if (!mounted || session?.fileBytes == null) return;
final fileName =
session?.fileName ?? 'kvitto.${session?.fileExtension ?? 'pdf'}';
final bytes = session!.fileBytes!;
setState(() {
_pickedFile = PlatformFile(
name: fileName,
size: bytes.length,
bytes: bytes,
extension: session.fileExtension,
);
});
if (session.items != null) {
await _loadInventory();
}
}
int? _categoryIdForProduct(int? productId) {
@@ -244,11 +268,18 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
ref.read(receiptImportSessionProvider.notifier).setFile(
file.bytes!,
file.extension?.toLowerCase() ?? '',
fileName: file.name,
);
}
Future<void> _submit() async {
if (_pickedFile == null) return;
final session = ref.read(receiptImportSessionProvider);
final submitBytes = _pickedFile?.bytes ?? session?.fileBytes;
if (submitBytes == null) return;
final submitFileName =
_pickedFile?.name ?? session?.fileName ?? 'kvitto.${session?.fileExtension ?? 'pdf'}';
setState(() { _isLoading = true; });
// Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här
@@ -256,8 +287,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final items = await repo.importReceiptFile(
bytes: _pickedFile!.bytes!,
filename: _pickedFile!.name,
bytes: submitBytes,
filename: submitFileName,
token: token,
);
if (!mounted) return;
@@ -508,7 +539,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
}
}
bool get _canSubmit => !_isLoading && _pickedFile?.bytes != null;
bool get _canSubmit {
if (_isLoading) return false;
if (_pickedFile?.bytes != null) return true;
final session = ref.read(receiptImportSessionProvider);
return session?.fileBytes != null;
}
int get _selectedCount => _selected.values.where((v) => v).length;
// ── Kvittobild / PDF-förhandsvisning ───────────────────────────────────────
@@ -570,6 +606,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final session = ref.watch(receiptImportSessionProvider);
final theme = Theme.of(context);
final items = session?.items;
final selectedFileName = _pickedFile?.name ?? session?.fileName;
final selectedFileSizeBytes =
_pickedFile?.size ?? session?.fileBytes?.length;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
@@ -584,12 +623,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file),
label: Text(_pickedFile == null ? 'Välj kvittofil' : _pickedFile!.name),
label: Text(selectedFileName == null ? 'Välj kvittofil' : selectedFileName),
),
if (_pickedFile != null) ...[
if (selectedFileSizeBytes != null) ...[
const SizedBox(height: 8),
Text(
'${(_pickedFile!.size / 1024).round()} KB',
'${(selectedFileSizeBytes / 1024).round()} KB',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
),
],
@@ -83,6 +83,15 @@ double? extractMultipackCountFromRawName(String rawName) {
return double.tryParse(match.group(1)!);
}
double? extractPieceCountFromRawName(String rawName) {
final match = RegExp(
r'(\d+(?:[\.,]\d+)?)\s*st\b',
caseSensitive: false,
).firstMatch(rawName);
if (match == null) return null;
return double.tryParse(match.group(1)!.replaceAll(',', '.'));
}
// ── Paketfältsinferens ────────────────────────────────────────────────────────
typedef PackageFields = ({
@@ -102,6 +111,18 @@ PackageFields inferPackageFields({
final safeCount = (quantity != null && quantity > 0) ? quantity : 1.0;
final extracted = extractPackageSizeFromRawName(rawName);
final multipackCount = extractMultipackCountFromRawName(rawName);
final pieceCount = extractPieceCountFromRawName(rawName);
// Exempel: "SALAMI PEPPAR 150G 2st" ska ge pack=150g och antal förpackningar=2.
if (extracted != null && pieceCount != null && pieceCount > 0) {
return (
packQuantity: extracted.packQuantity,
packUnit: extracted.packUnit,
packageCount: pieceCount,
totalQuantity: extracted.packQuantity * pieceCount,
totalUnit: extracted.packUnit,
);
}
// Om rånamnet innehåller storlek (t.ex. "5dl") och enhet saknas eller är
// paketliknande — använd extraherad storlek.
@@ -132,11 +153,14 @@ PackageFields inferPackageFields({
}
if (isPackageLikeUnit(normalizedUnit) && extracted != null) {
final packageCount = pieceCount != null && pieceCount > 0
? pieceCount
: quantity;
return (
packQuantity: extracted.packQuantity,
packUnit: extracted.packUnit,
packageCount: quantity,
totalQuantity: extracted.packQuantity * quantity,
packageCount: packageCount,
totalQuantity: extracted.packQuantity * packageCount,
totalUnit: extracted.packUnit,
);
}