feat: enhance receipt import functionality with category selection and PDF opening support
This commit is contained in:
@@ -13,6 +13,20 @@ import '../../features/admin/domain/admin_category_node.dart';
|
|||||||
class CategoryThenProductPicker {
|
class CategoryThenProductPicker {
|
||||||
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.
|
/// Samlar alla ID:n för [node] och alla dess ättlingar rekursivt.
|
||||||
static Set<int> _collectIds(AdminCategoryNode node) {
|
static Set<int> _collectIds(AdminCategoryNode node) {
|
||||||
final ids = <int>{node.id};
|
final ids = <int>{node.id};
|
||||||
@@ -74,7 +88,7 @@ class CategoryThenProductPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Samla alla kategori-IDs i den valda grenen (inkl. ättlingar)
|
// Samla alla kategori-IDs i den valda grenen (inkl. ättlingar)
|
||||||
final categoryIds = _collectIds(selectedCategory!);
|
final categoryIds = _collectIds(selectedCategory);
|
||||||
|
|
||||||
// Filtrera produkter på dessa kategorier
|
// Filtrera produkter på dessa kategorier
|
||||||
final filtered = products
|
final filtered = products
|
||||||
@@ -93,12 +107,39 @@ class CategoryThenProductPicker {
|
|||||||
context,
|
context,
|
||||||
products: useList,
|
products: useList,
|
||||||
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,
|
initialQuery: initialQuery,
|
||||||
onCreate: onCreateBound,
|
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 ────────────────────────────────────────────────
|
// ── Kategoriträdets bottenark ────────────────────────────────────────────────
|
||||||
@@ -293,7 +334,7 @@ class _CategoryTileState extends State<_CategoryTile> {
|
|||||||
dense: true,
|
dense: true,
|
||||||
selected: isPreselected,
|
selected: isPreselected,
|
||||||
selectedColor: theme.colorScheme.primary,
|
selectedColor: theme.colorScheme.primary,
|
||||||
selectedTileColor: theme.colorScheme.primaryContainer.withOpacity(0.3),
|
selectedTileColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.label_outline,
|
Icons.label_outline,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
|||||||
@@ -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 ImportDestination { inventory, pantry }
|
||||||
|
|
||||||
|
enum CategorySelectionSource { ai, manual }
|
||||||
|
|
||||||
// ── Per-rad redigeringstillstånd ──────────────────────────────────────────────
|
// ── Per-rad redigeringstillstånd ──────────────────────────────────────────────
|
||||||
|
|
||||||
class ItemEdit {
|
class ItemEdit {
|
||||||
final int? productId;
|
final int? productId;
|
||||||
final String? productName;
|
final String? productName;
|
||||||
|
final int? categoryId;
|
||||||
|
final String? categoryPath;
|
||||||
|
final CategorySelectionSource? categorySource;
|
||||||
final double? quantity;
|
final double? quantity;
|
||||||
final String? unit;
|
final String? unit;
|
||||||
final ImportDestination destination;
|
final ImportDestination destination;
|
||||||
@@ -18,6 +23,9 @@ class ItemEdit {
|
|||||||
const ItemEdit({
|
const ItemEdit({
|
||||||
this.productId,
|
this.productId,
|
||||||
this.productName,
|
this.productName,
|
||||||
|
this.categoryId,
|
||||||
|
this.categoryPath,
|
||||||
|
this.categorySource,
|
||||||
this.quantity,
|
this.quantity,
|
||||||
this.unit,
|
this.unit,
|
||||||
this.destination = ImportDestination.inventory,
|
this.destination = ImportDestination.inventory,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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_paths.dart';
|
||||||
import '../../../core/api/api_providers.dart';
|
import '../../../core/api/api_providers.dart';
|
||||||
import '../../../core/ui/category_then_product_picker.dart';
|
import '../../../core/ui/category_then_product_picker.dart';
|
||||||
import '../../../core/ui/product_picker_field.dart';
|
import '../../../core/ui/product_picker_field.dart';
|
||||||
|
import '../../../core/utils/pdf_opener.dart';
|
||||||
import '../../../core/utils/global_error_handler.dart';
|
import '../../../core/utils/global_error_handler.dart';
|
||||||
import '../../admin/data/admin_repository.dart';
|
import '../../admin/data/admin_repository.dart';
|
||||||
import '../../admin/domain/admin_category_node.dart';
|
import '../../admin/domain/admin_category_node.dart';
|
||||||
@@ -18,11 +18,10 @@ import '../data/import_providers.dart';
|
|||||||
import '../data/receipt_import_session.dart';
|
import '../data/receipt_import_session.dart';
|
||||||
import '../domain/parsed_receipt_item.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;
|
typedef _Destination = ImportDestination;
|
||||||
|
|
||||||
|
enum _ProductEntryMode { existing, create }
|
||||||
|
|
||||||
// ── Redigeringstillstånd per rad ─────────────────────────────────────────────
|
// ── Redigeringstillstånd per rad ─────────────────────────────────────────────
|
||||||
|
|
||||||
typedef _ItemEdit = ItemEdit;
|
typedef _ItemEdit = ItemEdit;
|
||||||
@@ -35,6 +34,7 @@ class _EditDialog extends StatefulWidget {
|
|||||||
final List<ProductOption> products;
|
final List<ProductOption> products;
|
||||||
final List<AdminCategoryNode> categoryTree;
|
final List<AdminCategoryNode> categoryTree;
|
||||||
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
|
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
|
||||||
|
final _ProductEntryMode? initialEntryMode;
|
||||||
|
|
||||||
const _EditDialog({
|
const _EditDialog({
|
||||||
required this.item,
|
required this.item,
|
||||||
@@ -42,6 +42,7 @@ class _EditDialog extends StatefulWidget {
|
|||||||
required this.products,
|
required this.products,
|
||||||
required this.categoryTree,
|
required this.categoryTree,
|
||||||
this.onCreate,
|
this.onCreate,
|
||||||
|
this.initialEntryMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -51,9 +52,18 @@ class _EditDialog extends StatefulWidget {
|
|||||||
class _EditDialogState extends State<_EditDialog> {
|
class _EditDialogState extends State<_EditDialog> {
|
||||||
late final TextEditingController _quantityCtrl;
|
late final TextEditingController _quantityCtrl;
|
||||||
late final TextEditingController _unitCtrl;
|
late final TextEditingController _unitCtrl;
|
||||||
|
late final TextEditingController _newProductNameCtrl;
|
||||||
int? _productId;
|
int? _productId;
|
||||||
String? _productName;
|
String? _productName;
|
||||||
|
int? _productCategoryId;
|
||||||
|
String? _productCategoryPath;
|
||||||
|
CategorySelectionSource? _productCategorySource;
|
||||||
|
int? _newCategoryId;
|
||||||
|
String? _newCategoryPath;
|
||||||
|
CategorySelectionSource? _newCategorySource;
|
||||||
_Destination _destination = _Destination.inventory;
|
_Destination _destination = _Destination.inventory;
|
||||||
|
_ProductEntryMode _entryMode = _ProductEntryMode.existing;
|
||||||
|
bool _isCreatingProduct = false;
|
||||||
// Lokal lista — utökas om nya produkter skapas under dialogen
|
// Lokal lista — utökas om nya produkter skapas under dialogen
|
||||||
late List<ProductOption> _localProducts;
|
late List<ProductOption> _localProducts;
|
||||||
|
|
||||||
@@ -63,22 +73,120 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
_productId = widget.current.productId;
|
_productId = widget.current.productId;
|
||||||
_productName = widget.current.productName;
|
_productName = widget.current.productName;
|
||||||
_destination = widget.current.destination;
|
_destination = widget.current.destination;
|
||||||
|
_entryMode = widget.initialEntryMode ??
|
||||||
|
(_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing);
|
||||||
_localProducts = List.of(widget.products);
|
_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(
|
_quantityCtrl = TextEditingController(
|
||||||
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
|
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
|
||||||
);
|
);
|
||||||
_unitCtrl = TextEditingController(
|
_unitCtrl = TextEditingController(
|
||||||
text: widget.current.unit ?? widget.item.unit ?? '',
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_quantityCtrl.dispose();
|
_quantityCtrl.dispose();
|
||||||
_unitCtrl.dispose();
|
_unitCtrl.dispose();
|
||||||
|
_newProductNameCtrl.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -118,6 +226,9 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
.cast<ProductOption?>()
|
.cast<ProductOption?>()
|
||||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||||
?.name;
|
?.name;
|
||||||
|
_productCategoryId = _categoryIdForProduct(id);
|
||||||
|
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
|
||||||
|
_productCategorySource = CategorySelectionSource.manual;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +241,12 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_productId = sugId;
|
_productId = sugId;
|
||||||
_productName = item.suggestedProductName;
|
_productName = item.suggestedProductName;
|
||||||
|
_productCategoryId = _categoryIdForProduct(sugId) ?? item.categorySuggestionId;
|
||||||
|
_productCategoryPath =
|
||||||
|
_categoryPathForCategoryId(_productCategoryId) ?? item.categorySuggestionPath;
|
||||||
|
_productCategorySource = item.categorySuggestionPath != null
|
||||||
|
? CategorySelectionSource.ai
|
||||||
|
: null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Öppna kategori → produkt med AI-föreslagen kategori förvald
|
// Ö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),
|
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Produktval: sök direkt eller välj via kategoriträd
|
// Produktval: befintlig produkt eller skapa ny från importnamnet
|
||||||
Row(
|
SegmentedButton<_ProductEntryMode>(
|
||||||
children: [
|
segments: const [
|
||||||
Expanded(
|
ButtonSegment(
|
||||||
child: ProductPickerField(
|
value: _ProductEntryMode.existing,
|
||||||
products: _localProducts,
|
icon: Icon(Icons.search, size: 16),
|
||||||
value: _productId,
|
label: Text('Befintlig'),
|
||||||
label: 'Produkt',
|
),
|
||||||
onChanged: (id) {
|
ButtonSegment(
|
||||||
setState(() {
|
value: _ProductEntryMode.create,
|
||||||
_productId = id;
|
icon: Icon(Icons.add_box_outlined, size: 16),
|
||||||
_productName = id == null
|
label: Text('Ny produkt'),
|
||||||
? null
|
),
|
||||||
: _localProducts
|
],
|
||||||
.cast<ProductOption?>()
|
selected: {_entryMode},
|
||||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
onSelectionChanged: (s) => setState(() => _entryMode = s.first),
|
||||||
?.name;
|
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<ProductOption?>()
|
||||||
|
.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(
|
if (_newCategoryPath != null) ...[
|
||||||
message: 'Välj via kategori',
|
const SizedBox(height: 8),
|
||||||
child: OutlinedButton(
|
Container(
|
||||||
style: OutlinedButton.styleFrom(
|
width: double.infinity,
|
||||||
minimumSize: const Size(44, 56),
|
padding: const EdgeInsets.all(12),
|
||||||
padding: EdgeInsets.zero,
|
decoration: BoxDecoration(
|
||||||
),
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
onPressed: () => openCategoryPicker(),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: const Icon(Icons.account_tree_outlined, size: 20),
|
),
|
||||||
|
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),
|
const SizedBox(height: 12),
|
||||||
if (_destination == _Destination.inventory) ...[
|
if (_destination == _Destination.inventory) ...[
|
||||||
Row(children: [
|
Row(children: [
|
||||||
@@ -247,19 +467,14 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Avbryt')),
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Avbryt')),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _productId == null ? null : () {
|
onPressed: _canConfirm ? _confirm : null,
|
||||||
Navigator.pop(
|
child: _isCreatingProduct
|
||||||
context,
|
? const SizedBox(
|
||||||
_ItemEdit(
|
width: 18,
|
||||||
productId: _productId,
|
height: 18,
|
||||||
productName: _productName,
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
quantity: double.tryParse(_quantityCtrl.text) ?? widget.item.quantity,
|
)
|
||||||
unit: _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim(),
|
: Text(_entryMode == _ProductEntryMode.create ? 'Skapa och välj' : 'OK'),
|
||||||
destination: _destination,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('OK'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -288,7 +503,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
// Produktlistan för pickern
|
// Produktlistan för pickern
|
||||||
List<ProductOption> _products = [];
|
List<ProductOption> _products = [];
|
||||||
bool _loadingProducts = false;
|
|
||||||
|
|
||||||
// Kategoriträdet för tvåstegs-picker
|
// Kategoriträdet för tvåstegs-picker
|
||||||
List<AdminCategoryNode> _categoryTree = [];
|
List<AdminCategoryNode> _categoryTree = [];
|
||||||
@@ -306,7 +520,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadProducts() async {
|
Future<void> _loadProducts() async {
|
||||||
setState(() => _loadingProducts = true);
|
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final api = ref.read(apiClientProvider);
|
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())),
|
.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>;
|
_categoryTree = results[2] as List<AdminCategoryNode>;
|
||||||
_loadingProducts = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
if (mounted) setState(() => _loadingProducts = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadInventory() async {
|
Future<void> _loadInventory() async {
|
||||||
@@ -409,6 +619,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
notifier.setEdit(i, _ItemEdit(
|
notifier.setEdit(i, _ItemEdit(
|
||||||
productId: pid,
|
productId: pid,
|
||||||
productName: name,
|
productName: name,
|
||||||
|
categoryId: it.categorySuggestionId,
|
||||||
|
categoryPath: it.categorySuggestionPath,
|
||||||
quantity: it.quantity,
|
quantity: it.quantity,
|
||||||
unit: it.unit,
|
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 item = _items![index];
|
||||||
final current = _edits[index] ??
|
final current = _edits[index] ??
|
||||||
_ItemEdit(
|
_ItemEdit(
|
||||||
productId: item.matchedProductId ?? item.suggestedProductId,
|
productId: item.matchedProductId ?? item.suggestedProductId,
|
||||||
productName: item.matchedProductName ?? item.suggestedProductName,
|
productName: item.matchedProductName ?? item.suggestedProductName,
|
||||||
|
categoryId: item.categorySuggestionId,
|
||||||
|
categoryPath: item.categorySuggestionPath,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
);
|
);
|
||||||
@@ -440,6 +657,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
current: current,
|
current: current,
|
||||||
products: _products,
|
products: _products,
|
||||||
categoryTree: _categoryTree,
|
categoryTree: _categoryTree,
|
||||||
|
initialEntryMode: initialEntryMode,
|
||||||
onCreate: (name, categoryId) async {
|
onCreate: (name, categoryId) async {
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
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),
|
icon: const Icon(Icons.open_in_new, size: 16),
|
||||||
label: const Text('Öppna PDF'),
|
label: const Text('Öppna PDF'),
|
||||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
final blob = html.Blob([bytes], 'application/pdf');
|
final opened = await openPdfBytes(bytes);
|
||||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
if (!context.mounted || opened) return;
|
||||||
html.window.open(url, '_blank');
|
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),
|
const SizedBox(height: 2),
|
||||||
if (hasProduct)
|
if (hasProduct)
|
||||||
Text(edit!.productName ?? '', style: theme.textTheme.bodySmall?.copyWith(
|
Wrap(
|
||||||
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
spacing: 6,
|
||||||
fontWeight: FontWeight.w500,
|
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)
|
else if (isSuggested)
|
||||||
Text('Förslag: ${item.suggestedProductName ?? ''}',
|
Text('Förslag: ${item.suggestedProductName ?? ''}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
||||||
else
|
else
|
||||||
Text('Ingen match — tryck för att välja produkt',
|
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)),
|
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) ...[
|
if (existingInv != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(children: [
|
Row(children: [
|
||||||
@@ -758,7 +1052,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
hasProduct ? Icons.check_circle : (isSuggested ? Icons.help_outline : Icons.error_outline),
|
hasProduct ? Icons.check_circle : (isSuggested ? Icons.help_outline : Icons.error_outline),
|
||||||
color: hasProduct
|
color: hasProduct
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: (isSuggested ? Colors.orange : theme.colorScheme.error),
|
: (isSuggested ? Colors.orange : theme.colorScheme.tertiary),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onTap: () => _openEditDialog(i),
|
onTap: () => _openEditDialog(i),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ dependencies:
|
|||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
file_picker: ^11.0.2
|
file_picker: ^11.0.2
|
||||||
|
web: ^1.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user