feat: implement receipt import functionality with editing capabilities and product selection
This commit is contained in:
@@ -2,11 +2,595 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/api/api_paths.dart';
|
||||
import '../../../core/api/api_providers.dart';
|
||||
import '../../../core/ui/product_picker_field.dart';
|
||||
import '../../../core/utils/global_error_handler.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../../inventory/data/inventory_providers.dart';
|
||||
import '../../inventory/domain/inventory_item.dart';
|
||||
import '../../pantry/data/pantry_providers.dart';
|
||||
import '../../pantry/domain/pantry_item.dart';
|
||||
import '../data/import_providers.dart';
|
||||
import '../domain/parsed_receipt_item.dart';
|
||||
|
||||
enum _Destination { inventory, pantry }
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Redigeringsdialog ─────────────────────────────────────────────────────────
|
||||
|
||||
class _EditDialog extends StatefulWidget {
|
||||
final ParsedReceiptItem item;
|
||||
final _ItemEdit current;
|
||||
final List<ProductOption> products;
|
||||
|
||||
const _EditDialog({
|
||||
required this.item,
|
||||
required this.current,
|
||||
required this.products,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EditDialog> createState() => _EditDialogState();
|
||||
}
|
||||
|
||||
class _EditDialogState extends State<_EditDialog> {
|
||||
late final TextEditingController _quantityCtrl;
|
||||
late final TextEditingController _unitCtrl;
|
||||
int? _productId;
|
||||
String? _productName;
|
||||
_Destination _destination = _Destination.inventory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_productId = widget.current.productId;
|
||||
_productName = widget.current.productName;
|
||||
_destination = widget.current.destination;
|
||||
_quantityCtrl = TextEditingController(
|
||||
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
|
||||
);
|
||||
_unitCtrl = TextEditingController(
|
||||
text: widget.current.unit ?? widget.item.unit ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_quantityCtrl.dispose();
|
||||
_unitCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final aiCategory = widget.item.categorySuggestionName;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// AI-kategorisuggestion
|
||||
if (aiCategory != null) ...[
|
||||
Wrap(
|
||||
children: [
|
||||
Chip(
|
||||
avatar: const Icon(Icons.auto_awesome, size: 14),
|
||||
label: Text('AI: $aiCategory',
|
||||
style: theme.textTheme.labelSmall),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
side: BorderSide(color: Colors.green.shade300),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Destination
|
||||
SegmentedButton<_Destination>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _Destination.inventory,
|
||||
icon: Icon(Icons.kitchen_outlined, size: 16),
|
||||
label: Text('Inventarie'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Destination.pantry,
|
||||
icon: Icon(Icons.inventory_2_outlined, size: 16),
|
||||
label: Text('Baslager'),
|
||||
),
|
||||
],
|
||||
selected: {_destination},
|
||||
onSelectionChanged: (s) => setState(() => _destination = s.first),
|
||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ProductPickerField(
|
||||
products: widget.products,
|
||||
value: _productId,
|
||||
label: 'Produkt',
|
||||
onChanged: (id) {
|
||||
setState(() {
|
||||
_productId = id;
|
||||
_productName = id == null
|
||||
? null
|
||||
: widget.products
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_destination == _Destination.inventory) ...[
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _quantityCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Antal', border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _unitCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Enhet', border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
] else ...[
|
||||
Text(
|
||||
'Baslager sparar bara produkt — ingen mängd eller enhet.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Avbryt')),
|
||||
FilledButton(
|
||||
onPressed: _productId == null ? null : () {
|
||||
Navigator.pop(
|
||||
context,
|
||||
_ItemEdit(
|
||||
productId: _productId,
|
||||
productName: _productName,
|
||||
quantity: double.tryParse(_quantityCtrl.text) ?? widget.item.quantity,
|
||||
unit: _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim(),
|
||||
destination: _destination,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Huvudwidget ───────────────────────────────────────────────────────────────
|
||||
|
||||
class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||
const ReceiptImportTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReceiptImportTab> createState() => _ReceiptImportTabState();
|
||||
}
|
||||
|
||||
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 = {};
|
||||
|
||||
// Produktlistan för pickern
|
||||
List<ProductOption> _products = [];
|
||||
bool _loadingProducts = false;
|
||||
|
||||
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
||||
Map<int, InventoryItem> _inventoryByProduct = {};
|
||||
|
||||
// Befintligt baslager: productId → PantryItem (för deduplicering)
|
||||
Set<int> _pantryProductIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProducts();
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
setState(() => _loadingProducts = true);
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.getJson(ProductApiPaths.list, token: token);
|
||||
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_products = list
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: e['name'] as String))
|
||||
.toList();
|
||||
_loadingProducts = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loadingProducts = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadInventory() async {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final invRepo = ref.read(inventoryRepositoryProvider);
|
||||
final pantryRepo = ref.read(pantryRepositoryProvider);
|
||||
final results = await Future.wait([
|
||||
invRepo.fetchInventory(token: token),
|
||||
pantryRepo.fetchPantry(token: token),
|
||||
]);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inventoryByProduct = {
|
||||
for (final item in results[0] as List<InventoryItem>) item.productId: item,
|
||||
};
|
||||
_pantryProductIds = {
|
||||
for (final item in results[1] as List<PantryItem>) item.productId,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Tyst fel — sammanslagningsfunktionen är valfri
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
setState(() {
|
||||
_pickedFile = result.files.first;
|
||||
_items = null;
|
||||
_selected.clear();
|
||||
_edits.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_pickedFile == null) return;
|
||||
setState(() { _isLoading = true; _items = null; _selected.clear(); _edits.clear(); });
|
||||
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final repo = ref.read(importRepositoryProvider);
|
||||
final items = await repo.importReceiptFile(
|
||||
bytes: _pickedFile!.bytes!,
|
||||
filename: _pickedFile!.name,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Ladda inventariet för att visa befintliga poster och möjliggöra sammanslagning
|
||||
await _loadInventory();
|
||||
} catch (e) {
|
||||
if (mounted) showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openEditDialog(int index) async {
|
||||
final item = _items![index];
|
||||
final current = _edits[index] ??
|
||||
_ItemEdit(
|
||||
productId: item.matchedProductId ?? item.suggestedProductId,
|
||||
productName: item.matchedProductName ?? item.suggestedProductName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
);
|
||||
|
||||
final result = await showDialog<_ItemEdit>(
|
||||
context: context,
|
||||
builder: (_) => _EditDialog(item: item, current: current, products: _products),
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
setState(() {
|
||||
_edits[index] = result;
|
||||
_selected[index] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addSelected() async {
|
||||
final items = _items;
|
||||
if (items == null) return;
|
||||
|
||||
final toAdd = <int>[];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (_selected[i] == true && _edits[i]?.productId != null) toAdd.add(i);
|
||||
}
|
||||
if (toAdd.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Välj produkter för alla markerade rader först.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
int created = 0;
|
||||
int merged = 0;
|
||||
int pantryAdded = 0;
|
||||
int pantrySkipped = 0;
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final invRepo = ref.read(inventoryRepositoryProvider);
|
||||
final pantryRepo = ref.read(pantryRepositoryProvider);
|
||||
|
||||
for (final i in toAdd) {
|
||||
final edit = _edits[i]!;
|
||||
final item = items[i];
|
||||
final pid = edit.productId!;
|
||||
|
||||
if (edit.destination == _Destination.pantry) {
|
||||
if (_pantryProductIds.contains(pid)) {
|
||||
pantrySkipped++;
|
||||
} else {
|
||||
await pantryRepo.createPantryItem(pid, token: token);
|
||||
pantryAdded++;
|
||||
}
|
||||
} else {
|
||||
final qty = edit.quantity ?? item.quantity ?? 1.0;
|
||||
final unit = edit.unit ?? item.unit ?? 'st';
|
||||
final existing = _inventoryByProduct[pid];
|
||||
if (existing != null) {
|
||||
await invRepo.updateInventoryItem(
|
||||
existing.id,
|
||||
{'quantity': existing.quantity + qty},
|
||||
token: token,
|
||||
);
|
||||
merged++;
|
||||
} else {
|
||||
await invRepo.createInventoryItem({
|
||||
'productId': pid,
|
||||
'quantity': qty,
|
||||
'unit': unit,
|
||||
if (item.brand != null) 'brand': item.brand,
|
||||
}, token: token);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final parts = <String>[
|
||||
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
||||
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
|
||||
if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager',
|
||||
if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager',
|
||||
];
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(parts.join(', ') + '.')),
|
||||
);
|
||||
// Avmarkera sparade rader och uppdatera inventariet
|
||||
setState(() {
|
||||
for (final i in toAdd) _selected[i] = false;
|
||||
});
|
||||
await _loadInventory();
|
||||
} catch (e) {
|
||||
if (mounted) showGlobalErrorDialog(context, 'Fel vid sparande: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canSubmit => !_isLoading && _pickedFile?.bytes != null;
|
||||
int get _selectedCount => _selected.values.where((v) => v).length;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final items = _items;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading ? null : _pickFile,
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: Text(_pickedFile == null ? 'Välj kvittofil' : _pickedFile!.name),
|
||||
),
|
||||
if (_pickedFile != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${(_pickedFile!.size / 1024).round()} KB',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
if (_isLoading) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tolkar kvittot — detta kan ta upp till en minut...',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
icon: const Icon(Icons.receipt_long_outlined),
|
||||
label: const Text('Importera kvitto'),
|
||||
),
|
||||
// ── Resultatlista ──────────────────────────────────────────────
|
||||
if (items != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
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;
|
||||
}),
|
||||
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...List.generate(items.length, (i) {
|
||||
final item = items[i];
|
||||
final edit = _edits[i];
|
||||
final isChecked = _selected[i] ?? false;
|
||||
final hasProduct = edit?.productId != null;
|
||||
final isMatched = item.matchedProductId != null;
|
||||
final isSuggested = item.suggestedProductId != null && item.matchedProductId == null;
|
||||
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
||||
? _inventoryByProduct[edit!.productId]
|
||||
: null;
|
||||
final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry
|
||||
? _pantryProductIds.contains(edit!.productId)
|
||||
: false;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: ListTile(
|
||||
leading: Checkbox(
|
||||
value: isChecked,
|
||||
onChanged: (v) => setState(() => _selected[i] = v ?? false),
|
||||
),
|
||||
title: Text(item.rawName, style: theme.textTheme.bodyMedium),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
[
|
||||
if ((edit?.quantity ?? item.quantity) != null)
|
||||
'${edit?.quantity ?? item.quantity}',
|
||||
if ((edit?.unit ?? item.unit) != null)
|
||||
edit?.unit ?? item.unit!,
|
||||
if (item.price != null) '· ${item.price} kr',
|
||||
].join(' '),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
if (hasProduct)
|
||||
Text(edit!.productName ?? '', style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
))
|
||||
else if (isSuggested)
|
||||
Text('Förslag: ${item.suggestedProductName ?? ''}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
||||
else
|
||||
Text('Ingen match — tryck för att välja produkt',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)),
|
||||
if (existingInv != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (edit?.quantity ?? item.quantity ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700),
|
||||
),
|
||||
]),
|
||||
],
|
||||
if (alreadyInPantry) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
Icon(Icons.inventory_2_outlined, size: 12, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 3),
|
||||
Text('Finns redan i baslager',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700)),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: Icon(
|
||||
hasProduct ? Icons.check_circle : (isSuggested ? Icons.help_outline : Icons.error_outline),
|
||||
color: hasProduct
|
||||
? Colors.green
|
||||
: (isSuggested ? Colors.orange : theme.colorScheme.error),
|
||||
size: 20,
|
||||
),
|
||||
onTap: () => _openEditDialog(i),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_selectedCount > 0 && !_isSaving) ? _addSelected : null,
|
||||
icon: _isSaving
|
||||
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.add_shopping_cart),
|
||||
label: Text(_selectedCount > 0 ? 'Lägg till $_selectedCount markerade' : 'Markera rader att lägga till'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||
const ReceiptImportTab({super.key});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user