feat: add receipt import session management with file handling and item editing support
This commit is contained in:
@@ -47,6 +47,7 @@ class CategoryThenProductPicker {
|
|||||||
required List<ProductOption> products,
|
required List<ProductOption> products,
|
||||||
int? currentProductId,
|
int? currentProductId,
|
||||||
int? preselectedCategoryId,
|
int? preselectedCategoryId,
|
||||||
|
String? initialQuery,
|
||||||
Future<ProductOption?> Function(String name, int categoryId)? onCreate,
|
Future<ProductOption?> Function(String name, int categoryId)? onCreate,
|
||||||
}) async {
|
}) async {
|
||||||
AdminCategoryNode? selectedCategory;
|
AdminCategoryNode? selectedCategory;
|
||||||
@@ -94,6 +95,7 @@ class CategoryThenProductPicker {
|
|||||||
value: currentProductId,
|
value: currentProductId,
|
||||||
label: 'Produkt i "${selectedCategory!.name}"',
|
label: 'Produkt i "${selectedCategory!.name}"',
|
||||||
categoryFilter: null, // redan förfiltrerat
|
categoryFilter: null, // redan förfiltrerat
|
||||||
|
initialQuery: initialQuery,
|
||||||
onCreate: onCreateBound,
|
onCreate: onCreateBound,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/data/pantry_providers.dart';
|
||||||
import '../../pantry/domain/pantry_item.dart';
|
import '../../pantry/domain/pantry_item.dart';
|
||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
|
import '../data/receipt_import_session.dart';
|
||||||
import '../domain/parsed_receipt_item.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 ─────────────────────────────────────────────
|
// ── Redigeringstillstånd per rad ─────────────────────────────────────────────
|
||||||
|
|
||||||
class _ItemEdit {
|
typedef _ItemEdit = 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Redigeringsdialog ─────────────────────────────────────────────────────────
|
// ── Redigeringsdialog ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -118,6 +108,7 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
products: _localProducts,
|
products: _localProducts,
|
||||||
currentProductId: _productId,
|
currentProductId: _productId,
|
||||||
preselectedCategoryId: preselectedCategoryId,
|
preselectedCategoryId: preselectedCategoryId,
|
||||||
|
initialQuery: item.rawName,
|
||||||
onCreate: onCreateWrapped,
|
onCreate: onCreateWrapped,
|
||||||
);
|
);
|
||||||
if (id != null && mounted) {
|
if (id != null && mounted) {
|
||||||
@@ -288,11 +279,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
PlatformFile? _pickedFile;
|
PlatformFile? _pickedFile;
|
||||||
List<ParsedReceiptItem>? _items;
|
|
||||||
|
|
||||||
// Checkbox-state och per-rad redigering
|
// Session-state lyfts till provider — överlever tabbyte
|
||||||
final Map<int, bool> _selected = {};
|
ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider);
|
||||||
final Map<int, _ItemEdit> _edits = {};
|
List<ParsedReceiptItem>? get _items => _session?.items;
|
||||||
|
Map<int, _ItemEdit> get _edits => _session?.edits ?? {};
|
||||||
|
Map<int, bool> get _selected => _session?.selected ?? {};
|
||||||
|
|
||||||
// Produktlistan för pickern
|
// Produktlistan för pickern
|
||||||
List<ProductOption> _products = [];
|
List<ProductOption> _products = [];
|
||||||
@@ -382,17 +374,19 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
withData: true,
|
withData: true,
|
||||||
);
|
);
|
||||||
if (result == null || result.files.isEmpty) return;
|
if (result == null || result.files.isEmpty) return;
|
||||||
setState(() {
|
final file = result.files.first;
|
||||||
_pickedFile = result.files.first;
|
setState(() => _pickedFile = file);
|
||||||
_items = null;
|
// Spara bildbytes i session så att förhandsvisningen överlever tabbyte
|
||||||
_selected.clear();
|
ref.read(receiptImportSessionProvider.notifier).setFile(
|
||||||
_edits.clear();
|
file.bytes!,
|
||||||
});
|
file.extension?.toLowerCase() ?? '',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
if (_pickedFile == null) return;
|
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 {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
@@ -403,24 +397,23 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||||
_items = items;
|
notifier.setItems(items);
|
||||||
// Förmarkera rader som har en träff
|
// Förmarkera rader som har en träff
|
||||||
for (var i = 0; i < items.length; i++) {
|
for (var i = 0; i < items.length; i++) {
|
||||||
final it = items[i];
|
final it = items[i];
|
||||||
final pid = it.matchedProductId ?? it.suggestedProductId;
|
final pid = it.matchedProductId ?? it.suggestedProductId;
|
||||||
_selected[i] = pid != null;
|
notifier.setSelected(i, pid != null);
|
||||||
if (pid != null) {
|
if (pid != null) {
|
||||||
final name = it.matchedProductName ?? it.suggestedProductName;
|
final name = it.matchedProductName ?? it.suggestedProductName;
|
||||||
_edits[i] = _ItemEdit(
|
notifier.setEdit(i, _ItemEdit(
|
||||||
productId: pid,
|
productId: pid,
|
||||||
productName: name,
|
productName: name,
|
||||||
quantity: it.quantity,
|
quantity: it.quantity,
|
||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
);
|
));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
// Ladda inventariet för att visa befintliga poster och möjliggöra sammanslagning
|
// Ladda inventariet för att visa befintliga poster och möjliggöra sammanslagning
|
||||||
await _loadInventory();
|
await _loadInventory();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -473,10 +466,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result != null && mounted) {
|
if (result != null && mounted) {
|
||||||
setState(() {
|
ref.read(receiptImportSessionProvider.notifier).setEdit(index, result);
|
||||||
_edits[index] = result;
|
ref.read(receiptImportSessionProvider.notifier).setSelected(index, true);
|
||||||
_selected[index] = true;
|
setState(() {});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,9 +543,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
SnackBar(content: Text(parts.join(', ') + '.')),
|
SnackBar(content: Text(parts.join(', ') + '.')),
|
||||||
);
|
);
|
||||||
// Avmarkera sparade rader och uppdatera inventariet
|
// Avmarkera sparade rader och uppdatera inventariet
|
||||||
setState(() {
|
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||||
for (final i in toAdd) _selected[i] = false;
|
for (final i in toAdd) notifier.setSelected(i, false);
|
||||||
});
|
setState(() {});
|
||||||
await _loadInventory();
|
await _loadInventory();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) showGlobalErrorDialog(context, 'Fel vid sparande: $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;
|
bool get _canSubmit => !_isLoading && _pickedFile?.bytes != null;
|
||||||
int get _selectedCount => _selected.values.where((v) => v).length;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final session = ref.watch(receiptImportSessionProvider);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final items = _items;
|
final items = session?.items;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -592,6 +643,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
|
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),
|
const SizedBox(height: 24),
|
||||||
if (_isLoading) ...[
|
if (_isLoading) ...[
|
||||||
const LinearProgressIndicator(),
|
const LinearProgressIndicator(),
|
||||||
@@ -617,7 +672,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() {
|
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'),
|
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||||
),
|
),
|
||||||
@@ -643,7 +701,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Checkbox(
|
leading: Checkbox(
|
||||||
value: isChecked,
|
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),
|
title: Text(item.rawName, style: theme.textTheme.bodyMedium),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
|
|||||||
Reference in New Issue
Block a user