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 =