feat: enhance receipt import functionality with category selection and PDF opening support

This commit is contained in:
Nils-Johan Gynther
2026-05-01 22:46:58 +02:00
parent 5c263a14df
commit 4cbd658fa0
7 changed files with 440 additions and 68 deletions
@@ -13,6 +13,20 @@ import '../../features/admin/domain/admin_category_node.dart';
class CategoryThenProductPicker {
CategoryThenProductPicker._();
static List<String>? _findPath(
List<AdminCategoryNode> nodes,
int id,
List<String> parents,
) {
for (final node in nodes) {
final path = [...parents, node.name];
if (node.id == id) return path;
final found = _findPath(node.children, id, path);
if (found != null) return found;
}
return null;
}
/// Samlar alla ID:n för [node] och alla dess ättlingar rekursivt.
static Set<int> _collectIds(AdminCategoryNode node) {
final ids = <int>{node.id};
@@ -74,7 +88,7 @@ class CategoryThenProductPicker {
}
// Samla alla kategori-IDs i den valda grenen (inkl. ättlingar)
final categoryIds = _collectIds(selectedCategory!);
final categoryIds = _collectIds(selectedCategory);
// Filtrera produkter på dessa kategorier
final filtered = products
@@ -93,12 +107,39 @@ class CategoryThenProductPicker {
context,
products: useList,
value: currentProductId,
label: 'Produkt i "${selectedCategory!.name}"',
label: 'Produkt i "${selectedCategory.name}"',
categoryFilter: null, // redan förfiltrerat
initialQuery: initialQuery,
onCreate: onCreateBound,
);
}
static Future<({int id, String name, String path})?> showCategorySheet(
BuildContext context, {
required List<AdminCategoryNode> categoryTree,
int? preselectedCategoryId,
}) async {
if (!context.mounted) return null;
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => _CategoryPickerSheet(
tree: categoryTree,
preselectedId: preselectedCategoryId,
onSelected: (node) => Navigator.pop(ctx, node),
),
);
if (selectedCategory == null) return null;
final path = _findPath(categoryTree, selectedCategory.id, const [])
?.join(' > ') ??
selectedCategory.name;
return (
id: selectedCategory.id,
name: selectedCategory.name,
path: path,
);
}
}
// ── Kategoriträdets bottenark ────────────────────────────────────────────────
@@ -293,7 +334,7 @@ class _CategoryTileState extends State<_CategoryTile> {
dense: true,
selected: isPreselected,
selectedColor: theme.colorScheme.primary,
selectedTileColor: theme.colorScheme.primaryContainer.withOpacity(0.3),
selectedTileColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
leading: Icon(
Icons.label_outline,
size: 16,
+6
View File
@@ -0,0 +1,6 @@
import 'dart:typed_data';
import 'pdf_opener_stub.dart'
if (dart.library.js_interop) 'pdf_opener_web.dart' as impl;
Future<bool> openPdfBytes(Uint8List bytes) => impl.openPdfBytes(bytes);
@@ -0,0 +1,3 @@
import 'dart:typed_data';
Future<bool> openPdfBytes(Uint8List bytes) async => false;
@@ -0,0 +1,19 @@
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:web/web.dart' as web;
Future<bool> openPdfBytes(Uint8List bytes) async {
final blob = web.Blob(
[bytes.toJS].toJS,
web.BlobPropertyBag(type: 'application/pdf'),
);
final url = web.URL.createObjectURL(blob);
final openedWindow = web.window.open(url, '_blank', 'noopener,noreferrer');
if (openedWindow == null) {
web.URL.revokeObjectURL(url);
return false;
}
web.URL.revokeObjectURL(url);
return true;
}
@@ -6,11 +6,16 @@ import '../domain/parsed_receipt_item.dart';
enum ImportDestination { inventory, pantry }
enum CategorySelectionSource { ai, manual }
// ── Per-rad redigeringstillstånd ──────────────────────────────────────────────
class ItemEdit {
final int? productId;
final String? productName;
final int? categoryId;
final String? categoryPath;
final CategorySelectionSource? categorySource;
final double? quantity;
final String? unit;
final ImportDestination destination;
@@ -18,6 +23,9 @@ class ItemEdit {
const ItemEdit({
this.productId,
this.productName,
this.categoryId,
this.categoryPath,
this.categorySource,
this.quantity,
this.unit,
this.destination = ImportDestination.inventory,
@@ -1,11 +1,11 @@
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/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../../core/utils/pdf_opener.dart';
import '../../../core/utils/global_error_handler.dart';
import '../../admin/data/admin_repository.dart';
import '../../admin/domain/admin_category_node.dart';
@@ -18,11 +18,10 @@ import '../data/import_providers.dart';
import '../data/receipt_import_session.dart';
import '../domain/parsed_receipt_item.dart';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html show Blob, Url, window;
typedef _Destination = ImportDestination;
enum _ProductEntryMode { existing, create }
// ── Redigeringstillstånd per rad ─────────────────────────────────────────────
typedef _ItemEdit = ItemEdit;
@@ -35,6 +34,7 @@ class _EditDialog extends StatefulWidget {
final List<ProductOption> products;
final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
final _ProductEntryMode? initialEntryMode;
const _EditDialog({
required this.item,
@@ -42,6 +42,7 @@ class _EditDialog extends StatefulWidget {
required this.products,
required this.categoryTree,
this.onCreate,
this.initialEntryMode,
});
@override
@@ -51,9 +52,18 @@ class _EditDialog extends StatefulWidget {
class _EditDialogState extends State<_EditDialog> {
late final TextEditingController _quantityCtrl;
late final TextEditingController _unitCtrl;
late final TextEditingController _newProductNameCtrl;
int? _productId;
String? _productName;
int? _productCategoryId;
String? _productCategoryPath;
CategorySelectionSource? _productCategorySource;
int? _newCategoryId;
String? _newCategoryPath;
CategorySelectionSource? _newCategorySource;
_Destination _destination = _Destination.inventory;
_ProductEntryMode _entryMode = _ProductEntryMode.existing;
bool _isCreatingProduct = false;
// Lokal lista — utökas om nya produkter skapas under dialogen
late List<ProductOption> _localProducts;
@@ -63,22 +73,120 @@ class _EditDialogState extends State<_EditDialog> {
_productId = widget.current.productId;
_productName = widget.current.productName;
_destination = widget.current.destination;
_entryMode = widget.initialEntryMode ??
(_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing);
_localProducts = List.of(widget.products);
_productCategoryId = widget.current.categoryId ?? _categoryIdForProduct(_productId);
_productCategoryPath = widget.current.categoryPath ?? _categoryPathForCategoryId(_productCategoryId);
_productCategorySource = widget.current.categorySource;
_newCategoryId = widget.current.categoryId ?? widget.item.categorySuggestionId;
_newCategoryPath = widget.current.categoryPath ?? widget.item.categorySuggestionPath;
_newCategorySource = widget.current.categorySource;
_quantityCtrl = TextEditingController(
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
);
_unitCtrl = TextEditingController(
text: widget.current.unit ?? widget.item.unit ?? '',
);
_newProductNameCtrl = TextEditingController(
text: widget.current.productName ?? widget.item.rawName,
);
}
int? _categoryIdForProduct(int? productId) {
if (productId == null) return null;
return _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == productId, orElse: () => null)
?.categoryId;
}
String? _categoryPathForCategoryId(int? categoryId) {
if (categoryId == null) return null;
List<String>? walk(List<AdminCategoryNode> nodes, List<String> parents) {
for (final node in nodes) {
final path = [...parents, node.name];
if (node.id == categoryId) return path;
final found = walk(node.children, path);
if (found != null) return found;
}
return null;
}
return walk(widget.categoryTree, const [])?.join(' > ');
}
@override
void dispose() {
_quantityCtrl.dispose();
_unitCtrl.dispose();
_newProductNameCtrl.dispose();
super.dispose();
}
Future<void> _openCreateCategoryPicker() async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: widget.categoryTree,
preselectedCategoryId: _newCategoryId ?? widget.item.categorySuggestionId,
);
if (selected == null || !mounted) return;
setState(() {
_newCategoryId = selected.id;
_newCategoryPath = selected.path;
_newCategorySource = CategorySelectionSource.manual;
});
}
bool get _canConfirm {
if (_entryMode == _ProductEntryMode.create) {
return !_isCreatingProduct &&
_newProductNameCtrl.text.trim().isNotEmpty &&
_newCategoryId != null;
}
return _productId != null;
}
Future<void> _confirm() async {
if (_entryMode == _ProductEntryMode.create) {
if (widget.onCreate == null || _newCategoryId == null) return;
setState(() => _isCreatingProduct = true);
try {
final newProduct = await widget.onCreate!(
_newProductNameCtrl.text.trim(),
_newCategoryId!,
);
if (newProduct == null || !mounted) return;
if (!_localProducts.any((p) => p.id == newProduct.id)) {
_localProducts = [..._localProducts, newProduct];
}
_productId = newProduct.id;
_productName = newProduct.name;
_productCategoryId = _newCategoryId;
_productCategoryPath = _newCategoryPath;
_productCategorySource = _newCategorySource ?? CategorySelectionSource.manual;
} finally {
if (mounted) setState(() => _isCreatingProduct = false);
}
if (!mounted || _productId == null) return;
}
Navigator.pop(
context,
_ItemEdit(
productId: _productId,
productName: _productName,
categoryId: _productCategoryId,
categoryPath: _productCategoryPath,
categorySource: _productCategorySource,
quantity: double.tryParse(_quantityCtrl.text) ?? widget.item.quantity,
unit: _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim(),
destination: _destination,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -118,6 +226,9 @@ class _EditDialogState extends State<_EditDialog> {
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource = CategorySelectionSource.manual;
});
}
}
@@ -130,6 +241,12 @@ class _EditDialogState extends State<_EditDialog> {
setState(() {
_productId = sugId;
_productName = item.suggestedProductName;
_productCategoryId = _categoryIdForProduct(sugId) ?? item.categorySuggestionId;
_productCategoryPath =
_categoryPathForCategoryId(_productCategoryId) ?? item.categorySuggestionPath;
_productCategorySource = item.categorySuggestionPath != null
? CategorySelectionSource.ai
: null;
});
} else {
// Öppna kategori → produkt med AI-föreslagen kategori förvald
@@ -182,7 +299,26 @@ class _EditDialogState extends State<_EditDialog> {
style: const ButtonStyle(visualDensity: VisualDensity.compact),
),
const SizedBox(height: 12),
// Produktval: sök direkt eller välj via kategoriträd
// Produktval: befintlig produkt eller skapa ny från importnamnet
SegmentedButton<_ProductEntryMode>(
segments: const [
ButtonSegment(
value: _ProductEntryMode.existing,
icon: Icon(Icons.search, size: 16),
label: Text('Befintlig'),
),
ButtonSegment(
value: _ProductEntryMode.create,
icon: Icon(Icons.add_box_outlined, size: 16),
label: Text('Ny produkt'),
),
],
selected: {_entryMode},
onSelectionChanged: (s) => setState(() => _entryMode = s.first),
style: const ButtonStyle(visualDensity: VisualDensity.compact),
),
const SizedBox(height: 12),
if (_entryMode == _ProductEntryMode.existing)
Row(
children: [
Expanded(
@@ -190,6 +326,7 @@ class _EditDialogState extends State<_EditDialog> {
products: _localProducts,
value: _productId,
label: 'Produkt',
initialQuery: item.rawName,
onChanged: (id) {
setState(() {
_productId = id;
@@ -216,7 +353,90 @@ class _EditDialogState extends State<_EditDialog> {
),
),
],
)
else ...[
TextField(
controller: _newProductNameCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Produktnamn',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openCreateCategoryPicker,
icon: const Icon(Icons.account_tree_outlined),
label: Text(
_newCategoryPath == null
? 'Välj kategori'
: 'Kategori: $_newCategoryPath',
overflow: TextOverflow.ellipsis,
),
),
),
if (_newCategoryPath != null) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vald kategori',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
_newCategoryPath!,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
if (item.categorySuggestionPath != null &&
item.categorySuggestionPath!.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
children: [
ActionChip(
avatar: Icon(
Icons.auto_awesome,
size: 14,
color: Colors.green.shade700,
),
label: Text(
'AI-förslag: ${item.categorySuggestionPath}',
style: theme.textTheme.labelSmall,
),
backgroundColor: Colors.green.shade50,
side: BorderSide(color: Colors.green.shade300),
visualDensity: VisualDensity.compact,
onPressed: item.categorySuggestionId == null
? null
: () => setState(() {
_newCategoryId = item.categorySuggestionId;
_newCategoryPath = item.categorySuggestionPath;
_newCategorySource = CategorySelectionSource.ai;
}),
),
],
),
],
],
const SizedBox(height: 12),
if (_destination == _Destination.inventory) ...[
Row(children: [
@@ -247,19 +467,14 @@ class _EditDialogState extends State<_EditDialog> {
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'),
onPressed: _canConfirm ? _confirm : null,
child: _isCreatingProduct
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_entryMode == _ProductEntryMode.create ? 'Skapa och välj' : 'OK'),
),
],
);
@@ -288,7 +503,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
// Produktlistan för pickern
List<ProductOption> _products = [];
bool _loadingProducts = false;
// Kategoriträdet för tvåstegs-picker
List<AdminCategoryNode> _categoryTree = [];
@@ -306,7 +520,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
}
Future<void> _loadProducts() async {
setState(() => _loadingProducts = true);
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
@@ -335,12 +548,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
];
_categoryTree = results[2] as List<AdminCategoryNode>;
_loadingProducts = false;
});
}
} catch (_) {
if (mounted) setState(() => _loadingProducts = false);
}
} catch (_) {}
}
Future<void> _loadInventory() async {
@@ -409,6 +619,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
notifier.setEdit(i, _ItemEdit(
productId: pid,
productName: name,
categoryId: it.categorySuggestionId,
categoryPath: it.categorySuggestionPath,
quantity: it.quantity,
unit: it.unit,
));
@@ -423,12 +635,17 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
}
}
Future<void> _openEditDialog(int index) async {
Future<void> _openEditDialog(
int index, {
_ProductEntryMode? initialEntryMode,
}) async {
final item = _items![index];
final current = _edits[index] ??
_ItemEdit(
productId: item.matchedProductId ?? item.suggestedProductId,
productName: item.matchedProductName ?? item.suggestedProductName,
categoryId: item.categorySuggestionId,
categoryPath: item.categorySuggestionPath,
quantity: item.quantity,
unit: item.unit,
);
@@ -440,6 +657,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
current: current,
products: _products,
categoryTree: _categoryTree,
initialEntryMode: initialEntryMode,
onCreate: (name, categoryId) async {
try {
final token = await ref.read(authStateProvider.future);
@@ -582,10 +800,14 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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');
onPressed: () async {
final opened = await openPdfBytes(bytes);
if (!context.mounted || opened) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'),
),
);
},
),
),
@@ -722,16 +944,88 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
),
const SizedBox(height: 2),
if (hasProduct)
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(edit!.productName ?? '', style: theme.textTheme.bodySmall?.copyWith(
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
fontWeight: FontWeight.w500,
))
)),
if (edit.categorySource != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: edit.categorySource == CategorySelectionSource.ai
? Colors.green.shade50
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: edit.categorySource == CategorySelectionSource.ai
? Colors.green.shade300
: theme.colorScheme.outlineVariant,
),
),
child: Text(
edit.categorySource == CategorySelectionSource.ai ? 'AI' : 'Manuell',
style: theme.textTheme.labelSmall?.copyWith(
color: edit.categorySource == CategorySelectionSource.ai
? Colors.green.shade800
: theme.colorScheme.onSurfaceVariant,
),
),
),
],
)
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)),
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.tertiary)),
if (hasProduct && edit?.categoryPath != null) ...[
const SizedBox(height: 2),
Text(
edit!.categoryPath!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (!hasProduct && !isSuggested) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => _openEditDialog(
i,
initialEntryMode: _ProductEntryMode.existing,
),
icon: const Icon(Icons.search, size: 16),
label: const Text('Välj befintlig'),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
OutlinedButton.icon(
onPressed: () => _openEditDialog(
i,
initialEntryMode: _ProductEntryMode.create,
),
icon: const Icon(Icons.add_box_outlined, size: 16),
label: const Text('Ny produkt'),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
],
if (existingInv != null) ...[
const SizedBox(height: 2),
Row(children: [
@@ -758,7 +1052,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
hasProduct ? Icons.check_circle : (isSuggested ? Icons.help_outline : Icons.error_outline),
color: hasProduct
? Colors.green
: (isSuggested ? Colors.orange : theme.colorScheme.error),
: (isSuggested ? Colors.orange : theme.colorScheme.tertiary),
size: 20,
),
onTap: () => _openEditDialog(i),
+1
View File
@@ -20,6 +20,7 @@ dependencies:
intl: ^0.20.2
shared_preferences: ^2.5.5
file_picker: ^11.0.2
web: ^1.1.1
dev_dependencies:
flutter_test: