feat(receipt-import): enhance bread category detection and improve session management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user