From 4cbd658fa03408c5b7bbace4efda2e76720b3586 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 1 May 2026 22:46:58 +0200 Subject: [PATCH] feat: enhance receipt import functionality with category selection and PDF opening support --- .../core/ui/category_then_product_picker.dart | 47 +- flutter/lib/core/utils/pdf_opener.dart | 6 + flutter/lib/core/utils/pdf_opener_stub.dart | 3 + flutter/lib/core/utils/pdf_opener_web.dart | 19 + .../import/data/receipt_import_session.dart | 8 + .../presentation/receipt_import_tab.dart | 424 +++++++++++++++--- flutter/pubspec.yaml | 1 + 7 files changed, 440 insertions(+), 68 deletions(-) create mode 100644 flutter/lib/core/utils/pdf_opener.dart create mode 100644 flutter/lib/core/utils/pdf_opener_stub.dart create mode 100644 flutter/lib/core/utils/pdf_opener_web.dart diff --git a/flutter/lib/core/ui/category_then_product_picker.dart b/flutter/lib/core/ui/category_then_product_picker.dart index 59bd4449..21a32760 100644 --- a/flutter/lib/core/ui/category_then_product_picker.dart +++ b/flutter/lib/core/ui/category_then_product_picker.dart @@ -13,6 +13,20 @@ import '../../features/admin/domain/admin_category_node.dart'; class CategoryThenProductPicker { CategoryThenProductPicker._(); + static List? _findPath( + List nodes, + int id, + List 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 _collectIds(AdminCategoryNode node) { final ids = {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 categoryTree, + int? preselectedCategoryId, + }) async { + if (!context.mounted) return null; + final selectedCategory = await showModalBottomSheet( + 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, diff --git a/flutter/lib/core/utils/pdf_opener.dart b/flutter/lib/core/utils/pdf_opener.dart new file mode 100644 index 00000000..82a777c0 --- /dev/null +++ b/flutter/lib/core/utils/pdf_opener.dart @@ -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 openPdfBytes(Uint8List bytes) => impl.openPdfBytes(bytes); \ No newline at end of file diff --git a/flutter/lib/core/utils/pdf_opener_stub.dart b/flutter/lib/core/utils/pdf_opener_stub.dart new file mode 100644 index 00000000..31ae644b --- /dev/null +++ b/flutter/lib/core/utils/pdf_opener_stub.dart @@ -0,0 +1,3 @@ +import 'dart:typed_data'; + +Future openPdfBytes(Uint8List bytes) async => false; \ No newline at end of file diff --git a/flutter/lib/core/utils/pdf_opener_web.dart b/flutter/lib/core/utils/pdf_opener_web.dart new file mode 100644 index 00000000..bb398f7f --- /dev/null +++ b/flutter/lib/core/utils/pdf_opener_web.dart @@ -0,0 +1,19 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +Future 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; +} \ No newline at end of file diff --git a/flutter/lib/features/import/data/receipt_import_session.dart b/flutter/lib/features/import/data/receipt_import_session.dart index 5550652b..19cfa746 100644 --- a/flutter/lib/features/import/data/receipt_import_session.dart +++ b/flutter/lib/features/import/data/receipt_import_session.dart @@ -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, diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 5916ef2f..319c9224 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -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 products; final List categoryTree; final Future 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 _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() + .firstWhere((p) => p?.id == productId, orElse: () => null) + ?.categoryId; + } + + String? _categoryPathForCategoryId(int? categoryId) { + if (categoryId == null) return null; + + List? walk(List nodes, List 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 _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 _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() .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,41 +299,144 @@ 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 - Row( - children: [ - Expanded( - child: ProductPickerField( - products: _localProducts, - value: _productId, - label: 'Produkt', - onChanged: (id) { - setState(() { - _productId = id; - _productName = id == null - ? null - : _localProducts - .cast() - .firstWhere((p) => p?.id == id, orElse: () => null) - ?.name; - }); - }, + // 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( + child: ProductPickerField( + products: _localProducts, + value: _productId, + label: 'Produkt', + initialQuery: item.rawName, + onChanged: (id) { + setState(() { + _productId = id; + _productName = id == null + ? null + : _localProducts + .cast() + .firstWhere((p) => p?.id == id, orElse: () => null) + ?.name; + }); + }, + ), + ), + const SizedBox(width: 8), + Tooltip( + message: 'Välj via kategori', + child: OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(44, 56), + padding: EdgeInsets.zero, + ), + onPressed: () => openCategoryPicker(), + child: const Icon(Icons.account_tree_outlined, size: 20), + ), + ), + ], + ) + 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, ), ), - const SizedBox(width: 8), - Tooltip( - message: 'Välj via kategori', - child: OutlinedButton( - style: OutlinedButton.styleFrom( - minimumSize: const Size(44, 56), - padding: EdgeInsets.zero, - ), - onPressed: () => openCategoryPicker(), - child: const Icon(Icons.account_tree_outlined, size: 20), + ), + 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 { // Produktlistan för pickern List _products = []; - bool _loadingProducts = false; // Kategoriträdet för tvåstegs-picker List _categoryTree = []; @@ -306,7 +520,6 @@ class _ReceiptImportTabState extends ConsumerState { } Future _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 { .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; - _loadingProducts = false; }); } - } catch (_) { - if (mounted) setState(() => _loadingProducts = false); - } + } catch (_) {} } Future _loadInventory() async { @@ -409,6 +619,8 @@ class _ReceiptImportTabState extends ConsumerState { 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 { } } - Future _openEditDialog(int index) async { + Future _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 { 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 { 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 { ), 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, - )) + 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 { 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), diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 61fa725a..16d6d05f 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -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: