feat: add receipt import session management with file handling and item editing support
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user