feat(web): improve web build configuration and accessibility
- Add source maps and web renderer build arguments with defaults - Configure Caddy with CSP headers, cache policies, and service worker handling - Defer loading of import screen for performance optimization - Add semantic labels to icons for accessibility - Update web index.html with Swedish language, meta tags, and description - Add robots.txt and lighthouse configuration - Add new planning documents and archive entries
This commit is contained in:
@@ -44,14 +44,17 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
final categoryData = await api.getJson(CategoryApiPaths.tree, token: token);
|
||||
final categoryData =
|
||||
await api.getJson(CategoryApiPaths.tree, token: token);
|
||||
final categoryList = categoryData is List<dynamic>
|
||||
? categoryData
|
||||
: (categoryData is Map<String, dynamic> && categoryData['items'] is List<dynamic>)
|
||||
: (categoryData is Map<String, dynamic> &&
|
||||
categoryData['items'] is List<dynamic>)
|
||||
? categoryData['items'] as List<dynamic>
|
||||
: const <dynamic>[];
|
||||
final tree = categoryList
|
||||
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.map((e) =>
|
||||
AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
if (!mounted) return;
|
||||
setState(() => _categoryTree = tree);
|
||||
@@ -142,12 +145,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRestoredSourceIfNeeded(FlyerImportResult result, String? token) async {
|
||||
Future<void> _loadRestoredSourceIfNeeded(
|
||||
FlyerImportResult result, String? token) async {
|
||||
if (result.sessionId == null || result.sourceAvailable != true) return;
|
||||
if (_pickedFile?.bytes != null) return;
|
||||
try {
|
||||
final repo = ref.read(importRepositoryProvider);
|
||||
final bytes = await repo.getFlyerSourceBytes(sessionId: result.sessionId!, token: token);
|
||||
final bytes = await repo.getFlyerSourceBytes(
|
||||
sessionId: result.sessionId!, token: token);
|
||||
if (!mounted) return;
|
||||
setState(() => _restoredSourceBytes = bytes);
|
||||
} catch (_) {
|
||||
@@ -250,7 +255,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
ref.invalidate(shoppingListItemsProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'),
|
||||
content: Text(
|
||||
'${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'),
|
||||
action: SnackBarAction(
|
||||
label: 'Öppna',
|
||||
onPressed: () => context.go('/inkopslista'),
|
||||
@@ -273,7 +279,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
int? selectedCategoryId = item.categoryId;
|
||||
String? selectedCategoryPath = item.category;
|
||||
|
||||
final payload = await showDialog<({String name, int? categoryId, String? categoryPath})>(
|
||||
final payload = await showDialog<
|
||||
({String name, int? categoryId, String? categoryPath})>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
@@ -295,7 +302,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
selectedCategoryPath == null || selectedCategoryPath!.isEmpty
|
||||
selectedCategoryPath == null ||
|
||||
selectedCategoryPath!.isEmpty
|
||||
? 'Ingen kategori vald'
|
||||
: selectedCategoryPath!,
|
||||
),
|
||||
@@ -307,7 +315,9 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
onPressed: _categoryTree.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
final selected = await CategoryThenProductPicker.showCategorySheet(
|
||||
final selected =
|
||||
await CategoryThenProductPicker
|
||||
.showCategorySheet(
|
||||
context,
|
||||
categoryTree: _categoryTree,
|
||||
preselectedCategoryId: selectedCategoryId,
|
||||
@@ -402,7 +412,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
String _formatPrice(double? price, String? unit) {
|
||||
if (price == null) return '';
|
||||
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
|
||||
final unitPart = (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : '';
|
||||
final unitPart =
|
||||
(unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : '';
|
||||
return '$raw kr$unitPart';
|
||||
}
|
||||
|
||||
@@ -412,13 +423,20 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
if (trimmedOffer.isEmpty || trimmedLimit.isEmpty) return trimmedOffer;
|
||||
|
||||
final escaped = RegExp.escape(trimmedLimit);
|
||||
final withoutLimit = trimmedOffer.replaceAll(RegExp(escaped, caseSensitive: false), '').trim();
|
||||
final withoutLeadingPunctuation = withoutLimit.replaceAll(RegExp(r'^[,.;:\s-]+'), '').trim();
|
||||
return withoutLeadingPunctuation.replaceAll(RegExp(r'[,.;:\s-]+$'), '').trim();
|
||||
final withoutLimit = trimmedOffer
|
||||
.replaceAll(RegExp(escaped, caseSensitive: false), '')
|
||||
.trim();
|
||||
final withoutLeadingPunctuation =
|
||||
withoutLimit.replaceAll(RegExp(r'^[,.;:\s-]+'), '').trim();
|
||||
return withoutLeadingPunctuation
|
||||
.replaceAll(RegExp(r'[,.;:\s-]+$'), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) {
|
||||
final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null;
|
||||
final hasOffer = item.isOffer ||
|
||||
(item.offerText?.trim().isNotEmpty ?? false) ||
|
||||
item.price != null;
|
||||
if (!hasOffer) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
@@ -493,7 +511,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 18),
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: Colors.amber.shade800, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Varningar (${warnings.length})',
|
||||
@@ -546,14 +565,16 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: const Text('Visa flyer'),
|
||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final opened = await openPdfBytes(bytes);
|
||||
if (!context.mounted || opened) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'),
|
||||
content: Text(
|
||||
'PDF kan bara öppnas direkt i webbversionen just nu.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -600,25 +621,29 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: (!_isLoading && _pickedFile?.bytes != null) ? _parseFlyer : null,
|
||||
onPressed: (!_isLoading && _pickedFile?.bytes != null)
|
||||
? _parseFlyer
|
||||
: null,
|
||||
icon: const Icon(Icons.auto_awesome),
|
||||
label: const Text('Importera flyer'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlyerPreview(theme),
|
||||
if (_isLoading) ...[
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
if (items.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildWarningsPanel(theme),
|
||||
if ((_result?.warnings ?? const []).isNotEmpty) const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${items.length} rader hittades', style: theme.textTheme.titleSmall),
|
||||
TextButton(
|
||||
_buildFlyerPreview(theme),
|
||||
if (_isLoading) ...[
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
if (items.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildWarningsPanel(theme),
|
||||
if ((_result?.warnings ?? const []).isNotEmpty)
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${items.length} rader hittades',
|
||||
style: theme.textTheme.titleSmall),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final target = selectedCount < items.length;
|
||||
setState(() {
|
||||
@@ -626,77 +651,92 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
_selected[i] = target;
|
||||
}
|
||||
});
|
||||
ref.read(flyerImportSessionProvider.notifier).setSelectedForAll(items.length, target);
|
||||
ref
|
||||
.read(flyerImportSessionProvider.notifier)
|
||||
.setSelectedForAll(items.length, target);
|
||||
},
|
||||
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child: Text(selectedCount < items.length
|
||||
? 'Välj alla'
|
||||
: 'Avmarkera alla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final priceText = _formatPrice(item.price, item.priceUnit);
|
||||
final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit);
|
||||
final comparisonText =
|
||||
_formatPrice(item.comparisonPrice, item.comparisonUnit);
|
||||
final limitText = item.offerLimitText?.trim();
|
||||
final sanitizedOfferText = item.offerText == null
|
||||
? ''
|
||||
: _removeLimitTextFromOfferText(item.offerText!, limitText);
|
||||
|
||||
return CheckboxListTile(
|
||||
value: _selected[index] ?? false,
|
||||
onChanged: (value) {
|
||||
final checked = value ?? false;
|
||||
setState(() => _selected[index] = checked);
|
||||
ref.read(flyerImportSessionProvider.notifier).setSelected(index, checked);
|
||||
},
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(item.rawName)),
|
||||
IconButton(
|
||||
tooltip: 'Redigera',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => _editItem(index, item),
|
||||
return CheckboxListTile(
|
||||
value: _selected[index] ?? false,
|
||||
onChanged: (value) {
|
||||
final checked = value ?? false;
|
||||
setState(() => _selected[index] = checked);
|
||||
ref
|
||||
.read(flyerImportSessionProvider.notifier)
|
||||
.setSelected(index, checked);
|
||||
},
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(item.rawName)),
|
||||
IconButton(
|
||||
tooltip: 'Redigera',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
semanticLabel: 'Redigera rad',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildQualityBadge(item, theme),
|
||||
const SizedBox(width: 8),
|
||||
_buildOfferBadge(item, theme),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (priceText.isNotEmpty) Text('Pris: $priceText'),
|
||||
if ((item.category ?? '').trim().isNotEmpty)
|
||||
Text('Kategori: ${item.category}'),
|
||||
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
|
||||
if (limitText != null && limitText.isNotEmpty)
|
||||
Text(
|
||||
'Begränsning: $limitText',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.orange.shade900,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
||||
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
|
||||
],
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
);
|
||||
onPressed: () => _editItem(index, item),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildQualityBadge(item, theme),
|
||||
const SizedBox(width: 8),
|
||||
_buildOfferBadge(item, theme),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (priceText.isNotEmpty) Text('Pris: $priceText'),
|
||||
if ((item.category ?? '').trim().isNotEmpty)
|
||||
Text('Kategori: ${item.category}'),
|
||||
if (comparisonText.isNotEmpty)
|
||||
Text('Jämförpris: $comparisonText'),
|
||||
if (limitText != null && limitText.isNotEmpty)
|
||||
Text(
|
||||
'Begränsning: $limitText',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.orange.shade900,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
||||
if (item.matchedProductName != null)
|
||||
Text('Match: ${item.matchedProductName}'),
|
||||
],
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_isSaving || selectedCount == 0) ? null : _planSelected,
|
||||
onPressed:
|
||||
(_isSaving || selectedCount == 0) ? null : _planSelected,
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.playlist_add_check),
|
||||
label: Text('Planera $selectedCount markerade'),
|
||||
|
||||
@@ -133,7 +133,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
|
||||
try {
|
||||
final globalData = await api.getJson(ProductApiPaths.list, token: token);
|
||||
final globalData =
|
||||
await api.getJson(ProductApiPaths.list, token: token);
|
||||
globalList = _extractItems(globalData);
|
||||
} catch (e, st) {
|
||||
globalFailed = true;
|
||||
@@ -146,7 +147,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
mineList = _extractItems(mineData);
|
||||
} catch (e, st) {
|
||||
privateFailed = true;
|
||||
debugPrint('ReceiptImportTab._loadProducts private products failed: $e');
|
||||
debugPrint(
|
||||
'ReceiptImportTab._loadProducts private products failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
} catch (e, st) {
|
||||
@@ -158,12 +160,16 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
if (!mounted) return;
|
||||
|
||||
final mergedProducts = [
|
||||
...globalList
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||
...mineList
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||
...globalList.cast<Map<String, dynamic>>().map((e) => (
|
||||
id: e['id'] as int,
|
||||
name: (e['canonicalName'] ?? e['name']) as String,
|
||||
categoryId: (e['categoryId'] as num?)?.toInt()
|
||||
)),
|
||||
...mineList.cast<Map<String, dynamic>>().map((e) => (
|
||||
id: e['id'] as int,
|
||||
name: (e['canonicalName'] ?? e['name']) as String,
|
||||
categoryId: (e['categoryId'] as num?)?.toInt()
|
||||
)),
|
||||
];
|
||||
final dedupedById = <int, ProductOption>{
|
||||
for (final product in mergedProducts) product.id: product,
|
||||
@@ -181,7 +187,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
if (_products.isEmpty && _categoryTree.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Kunde inte ladda produkter eller kategorier. Försök igen.')),
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Kunde inte ladda produkter eller kategorier. Försök igen.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -211,7 +219,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 18),
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: Colors.amber.shade800, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -244,7 +253,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inventoryByProduct = {
|
||||
for (final item in results[0] as List<InventoryItem>) item.productId: item,
|
||||
for (final item in results[0] as List<InventoryItem>)
|
||||
item.productId: item,
|
||||
};
|
||||
_pantryProductIds = {
|
||||
for (final item in results[1] as List<PantryItem>) item.productId,
|
||||
@@ -267,10 +277,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
setState(() => _pickedFile = file);
|
||||
// Spara bildbytes i session så att förhandsvisningen överlever tabbyte
|
||||
ref.read(receiptImportSessionProvider.notifier).setFile(
|
||||
file.bytes!,
|
||||
file.extension?.toLowerCase() ?? '',
|
||||
fileName: file.name,
|
||||
);
|
||||
file.bytes!,
|
||||
file.extension?.toLowerCase() ?? '',
|
||||
fileName: file.name,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
@@ -278,10 +288,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final submitBytes = _pickedFile?.bytes ?? session?.fileBytes;
|
||||
if (submitBytes == null) return;
|
||||
|
||||
final submitFileName =
|
||||
_pickedFile?.name ?? session?.fileName ?? 'kvitto.${session?.fileExtension ?? 'pdf'}';
|
||||
final submitFileName = _pickedFile?.name ??
|
||||
session?.fileName ??
|
||||
'kvitto.${session?.fileExtension ?? 'pdf'}';
|
||||
|
||||
setState(() { _isLoading = true; });
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
// Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här
|
||||
|
||||
try {
|
||||
@@ -307,8 +320,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
unit: it.unit,
|
||||
);
|
||||
final name = it.matchedProductName ?? it.suggestedProductName;
|
||||
final resolvedCategoryId =
|
||||
it.categorySuggestionId ?? (pid != null ? _categoryIdForProduct(pid) : null);
|
||||
final resolvedCategoryId = it.categorySuggestionId ??
|
||||
(pid != null ? _categoryIdForProduct(pid) : null);
|
||||
final resolvedCategoryPath =
|
||||
it.categorySuggestionPath ?? _lookup.pathFor(resolvedCategoryId);
|
||||
|
||||
@@ -337,7 +350,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
// 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');
|
||||
if (mounted)
|
||||
showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
@@ -379,13 +393,15 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
_ItemEdit(
|
||||
productId: item.matchedProductId ?? item.suggestedProductId,
|
||||
productName: item.matchedProductName ?? item.suggestedProductName,
|
||||
categoryId: item.categorySuggestionId ??
|
||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||
categoryPath: item.categorySuggestionPath ??
|
||||
_lookup.pathFor(
|
||||
item.categorySuggestionId ??
|
||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||
),
|
||||
categoryId: item.categorySuggestionId ??
|
||||
_categoryIdForProduct(
|
||||
item.matchedProductId ?? item.suggestedProductId),
|
||||
categoryPath: item.categorySuggestionPath ??
|
||||
_lookup.pathFor(
|
||||
item.categorySuggestionId ??
|
||||
_categoryIdForProduct(
|
||||
item.matchedProductId ?? item.suggestedProductId),
|
||||
),
|
||||
categorySource: item.categorySuggestionId != null
|
||||
? CategorySelectionSource.ai
|
||||
: null,
|
||||
@@ -432,7 +448,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
throw Exception('API-svar saknar produktnamn.');
|
||||
}
|
||||
|
||||
final int? returnedCategoryId = raw['categoryId'] is num
|
||||
final int? returnedCategoryId = raw['categoryId'] is num
|
||||
? (raw['categoryId'] as num).toInt()
|
||||
: categoryId;
|
||||
|
||||
@@ -454,7 +470,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showReceiptPreview(BuildContext context, List<ParsedReceiptItem> items) async {
|
||||
Future<void> _showReceiptPreview(
|
||||
BuildContext context, List<ParsedReceiptItem> items) async {
|
||||
if (!context.mounted) return;
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -468,7 +485,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final repo = ref.read(importRepositoryProvider);
|
||||
final help = await repo.fetchHelpTextByKey('receipt_import', token: token);
|
||||
final help =
|
||||
await repo.fetchHelpTextByKey('receipt_import', token: token);
|
||||
if (!mounted) return;
|
||||
await _showHelpDialog(help);
|
||||
} catch (e) {
|
||||
@@ -503,7 +521,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('Scope: ${help.scope}')),
|
||||
if (updatedAtText != null) Chip(label: Text('Uppdaterad: $updatedAtText')),
|
||||
if (updatedAtText != null)
|
||||
Chip(label: Text('Uppdaterad: $updatedAtText')),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -532,7 +551,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final remainingEdits = <int, ItemEdit>{};
|
||||
final remainingSelected = <int, bool>{};
|
||||
var newIndex = 0;
|
||||
|
||||
|
||||
for (var oldIndex = 0; oldIndex < items.length; oldIndex++) {
|
||||
if (oldIndex != index) {
|
||||
if (_edits.containsKey(oldIndex)) {
|
||||
@@ -569,7 +588,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
if (productId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Välj produkt för raden innan du redigerar alias.')),
|
||||
const SnackBar(
|
||||
content: Text('Välj produkt för raden innan du redigerar alias.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -636,7 +656,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
}
|
||||
if (toAdd.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Välj produkter för alla markerade rader först.')),
|
||||
const SnackBar(
|
||||
content: Text('Välj produkter för alla markerade rader först.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -657,7 +678,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
'rawName': item.rawName,
|
||||
'quantity': edit.quantity ?? item.quantity ?? 0,
|
||||
'unit': (edit.unit ?? item.unit ?? 'st').trim(),
|
||||
'destination': edit.destination == _Destination.pantry ? 'pantry' : 'inventory',
|
||||
'destination':
|
||||
edit.destination == _Destination.pantry ? 'pantry' : 'inventory',
|
||||
'productId': pid,
|
||||
};
|
||||
|
||||
@@ -668,14 +690,19 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
// Päckfält för inventory
|
||||
if (edit.destination == _Destination.inventory) {
|
||||
if (edit.packQuantity != null) saveItem['packQuantity'] = edit.packQuantity;
|
||||
if (edit.packQuantity != null)
|
||||
saveItem['packQuantity'] = edit.packQuantity;
|
||||
if (edit.packUnit != null) saveItem['packUnit'] = edit.packUnit;
|
||||
if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount;
|
||||
if (edit.packageCount != null)
|
||||
saveItem['packageCount'] = edit.packageCount;
|
||||
}
|
||||
|
||||
// Lär in alias bara om användaren uttryckligen valt det
|
||||
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
|
||||
if (edit.learnAlias && item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
||||
final alreadyAliasMatch =
|
||||
item.matchedVia == 'alias' && item.matchedProductId == pid;
|
||||
if (edit.learnAlias &&
|
||||
item.rawName.trim().isNotEmpty &&
|
||||
!alreadyAliasMatch) {
|
||||
saveItem['learnAlias'] = true;
|
||||
if (edit.learnAliasGlobally) {
|
||||
saveItem['learnAliasGlobally'] = true;
|
||||
@@ -685,8 +712,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
// Lär in enhetsmappning för inventory
|
||||
if (edit.destination == _Destination.inventory) {
|
||||
final originalUnit = (item.unit ?? '').trim().toLowerCase();
|
||||
final preferredUnit = (edit.unit ?? item.unit ?? 'st').trim().toLowerCase();
|
||||
if (originalUnit.isNotEmpty && preferredUnit.isNotEmpty && originalUnit != preferredUnit) {
|
||||
final preferredUnit =
|
||||
(edit.unit ?? item.unit ?? 'st').trim().toLowerCase();
|
||||
if (originalUnit.isNotEmpty &&
|
||||
preferredUnit.isNotEmpty &&
|
||||
originalUnit != preferredUnit) {
|
||||
saveItem['learnUnitMapping'] = true;
|
||||
}
|
||||
}
|
||||
@@ -708,22 +738,25 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final pantryAdded = response['pantryAdded'] as int? ?? 0;
|
||||
final pantrySkipped = response['pantrySkipped'] as int? ?? 0;
|
||||
final aliasesLearned = response['aliasesLearned'] as int? ?? 0;
|
||||
final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0;
|
||||
final flyerAutoSync = response['flyerAutoSync'] as Map<String, dynamic>?;
|
||||
final errors = response['errors'] as List? ?? [];
|
||||
final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0;
|
||||
final flyerAutoSync = response['flyerAutoSync'] as Map<String, dynamic>?;
|
||||
final errors = response['errors'] as List? ?? [];
|
||||
|
||||
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 (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',
|
||||
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
||||
if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda',
|
||||
if ((flyerAutoSync?['bought'] as int? ?? 0) > 0)
|
||||
'${flyerAutoSync?['bought']} planerade flyer-varor markerade som köpta',
|
||||
if ((flyerAutoSync?['ambiguous'] as int? ?? 0) > 0)
|
||||
'${flyerAutoSync?['ambiguous']} flyer-matchningar kräver kontroll',
|
||||
];
|
||||
if (unitMappingsLearned > 0)
|
||||
'$unitMappingsLearned enhetsmappningar inlärda',
|
||||
if ((flyerAutoSync?['bought'] as int? ?? 0) > 0)
|
||||
'${flyerAutoSync?['bought']} planerade flyer-varor markerade som köpta',
|
||||
if ((flyerAutoSync?['ambiguous'] as int? ?? 0) > 0)
|
||||
'${flyerAutoSync?['ambiguous']} flyer-matchningar kräver kontroll',
|
||||
];
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
final errorParts = <String>[];
|
||||
@@ -748,7 +781,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final remainingItems = <ParsedReceiptItem>[];
|
||||
final remainingEdits = <int, ItemEdit>{};
|
||||
final remainingSelected = <int, bool>{};
|
||||
|
||||
|
||||
var newIndex = 0;
|
||||
for (var oldIndex = 0; oldIndex < items.length; oldIndex++) {
|
||||
if (!addedIndexSet.contains(oldIndex)) {
|
||||
@@ -762,7 +795,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
newIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||
if (remainingItems.isEmpty) {
|
||||
notifier.clear();
|
||||
@@ -788,6 +821,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final session = ref.read(receiptImportSessionProvider);
|
||||
return session?.fileBytes != null;
|
||||
}
|
||||
|
||||
int get _selectedCount => _selected.values.where((v) => v).length;
|
||||
|
||||
// ── Kvittobild / PDF-förhandsvisning ───────────────────────────────────────
|
||||
@@ -814,13 +848,15 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: const Text('Visa kvitto'),
|
||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact),
|
||||
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.'),
|
||||
content: Text(
|
||||
'PDF kan bara öppnas direkt i webbversionen just nu.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -852,7 +888,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
'alias' => ('Alias', Colors.teal.shade50, Colors.teal.shade800),
|
||||
'wordmatch' => ('Ordmatch', Colors.blue.shade50, Colors.blue.shade800),
|
||||
'ai' => ('AI-kategori', Colors.purple.shade50, Colors.purple.shade800),
|
||||
_ => ('Matchad', theme.colorScheme.surfaceContainerHighest, theme.colorScheme.onSurfaceVariant),
|
||||
_ => (
|
||||
'Matchad',
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
@@ -864,7 +904,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: fg, fontWeight: FontWeight.w600),
|
||||
style: theme.textTheme.labelSmall
|
||||
?.copyWith(color: fg, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -876,8 +917,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final items = session?.items;
|
||||
final selectedFileName = _pickedFile?.name ?? session?.fileName;
|
||||
final selectedFileSizeBytes =
|
||||
_pickedFile?.size ?? session?.fileBytes?.length;
|
||||
|
||||
_pickedFile?.size ?? session?.fileBytes?.length;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -890,7 +930,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
Expanded(
|
||||
child: 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),
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -911,13 +952,15 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading ? null : _pickFile,
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: Text(selectedFileName == null ? 'Välj kvittofil' : selectedFileName),
|
||||
label: Text(
|
||||
selectedFileName == null ? 'Välj kvittofil' : selectedFileName),
|
||||
),
|
||||
if (selectedFileSizeBytes != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${(selectedFileSizeBytes / 1024).round()} KB',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
],
|
||||
// ── Förhandsvisning av kvitto ────────────────────────────────────────
|
||||
@@ -931,7 +974,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tolkar kvittot — detta kan ta upp till en minut...',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
@@ -951,21 +995,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
||||
Text('${items.length} rader — tryck för att redigera',
|
||||
style: theme.textTheme.titleSmall),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: items.isEmpty ? null : () => _showReceiptPreview(context, items),
|
||||
onPressed: items.isEmpty
|
||||
? null
|
||||
: () => _showReceiptPreview(context, items),
|
||||
icon: const Icon(Icons.description_outlined),
|
||||
label: const Text('Se kvitto'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||
notifier.setSelectedForAll(items.length, _selectedCount < items.length);
|
||||
final notifier =
|
||||
ref.read(receiptImportSessionProvider.notifier);
|
||||
notifier.setSelectedForAll(
|
||||
items.length, _selectedCount < items.length);
|
||||
}),
|
||||
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||
child: Text(_selectedCount < items.length
|
||||
? 'Välj alla'
|
||||
: 'Avmarkera alla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -978,39 +1029,48 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) {
|
||||
return _ReceiptImportResultRow(
|
||||
index: i,
|
||||
item: items[i],
|
||||
edit: _edits[i],
|
||||
existingInventoryByProduct: _inventoryByProduct,
|
||||
pantryProductIds: _pantryProductIds,
|
||||
onCheckedChanged: (v) {
|
||||
ref.read(receiptImportSessionProvider.notifier).setSelected(i, v);
|
||||
},
|
||||
onEditRequested: () => _openEditDialog(i),
|
||||
onSelectExistingRequested: () => _openEditDialog(
|
||||
i,
|
||||
initialEntryMode: ImportProductEntryMode.existing,
|
||||
),
|
||||
onCreateRequested: () => _openEditDialog(
|
||||
i,
|
||||
initialEntryMode: ImportProductEntryMode.create,
|
||||
),
|
||||
onAliasEditRequested: () => _editAliasForItem(i),
|
||||
onDeleteRequested: () => _deleteItem(i),
|
||||
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
||||
);
|
||||
},
|
||||
return _ReceiptImportResultRow(
|
||||
index: i,
|
||||
item: items[i],
|
||||
edit: _edits[i],
|
||||
existingInventoryByProduct: _inventoryByProduct,
|
||||
pantryProductIds: _pantryProductIds,
|
||||
onCheckedChanged: (v) {
|
||||
ref
|
||||
.read(receiptImportSessionProvider.notifier)
|
||||
.setSelected(i, v);
|
||||
},
|
||||
onEditRequested: () => _openEditDialog(i),
|
||||
onSelectExistingRequested: () => _openEditDialog(
|
||||
i,
|
||||
initialEntryMode: ImportProductEntryMode.existing,
|
||||
),
|
||||
onCreateRequested: () => _openEditDialog(
|
||||
i,
|
||||
initialEntryMode: ImportProductEntryMode.create,
|
||||
),
|
||||
onAliasEditRequested: () => _editAliasForItem(i),
|
||||
onDeleteRequested: () => _deleteItem(i),
|
||||
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_selectedCount > 0 && !_isSaving) ? _addSelected : null,
|
||||
onPressed:
|
||||
(_selectedCount > 0 && !_isSaving) ? _addSelected : null,
|
||||
icon: _isSaving
|
||||
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
? 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'),
|
||||
label: Text(_selectedCount > 0
|
||||
? 'Lägg till $_selectedCount markerade'
|
||||
: 'Markera rader att lägga till'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1061,9 +1121,10 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
||||
final isMatched = item.matchedProductId != null;
|
||||
final isSuggested =
|
||||
item.suggestedProductId != null && item.matchedProductId == null;
|
||||
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
||||
? existingInventoryByProduct[edit!.productId]
|
||||
: null;
|
||||
final existingInv =
|
||||
edit?.productId != null && edit?.destination != _Destination.pantry
|
||||
? existingInventoryByProduct[edit!.productId]
|
||||
: null;
|
||||
final inferredForPreview = inferPackageFields(
|
||||
rawName: item.rawName,
|
||||
quantity: edit?.quantity ?? item.quantity,
|
||||
@@ -1075,7 +1136,10 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
||||
edit?.packQuantity ?? inferredForPreview.packQuantity;
|
||||
final previewIncomingQty = previewPackQuantity != null
|
||||
? (previewPackQuantity * previewPackageCount)
|
||||
: (edit?.quantity ?? inferredForPreview.totalQuantity ?? item.quantity ?? 0);
|
||||
: (edit?.quantity ??
|
||||
inferredForPreview.totalQuantity ??
|
||||
item.quantity ??
|
||||
0);
|
||||
final previewIncomingUnit = edit?.packUnit ??
|
||||
inferredForPreview.packUnit ??
|
||||
edit?.unit ??
|
||||
@@ -1089,9 +1153,10 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
||||
existingInv.unit,
|
||||
);
|
||||
final canMergePreview = existingInv != null && convertedPreviewQty != null;
|
||||
final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry
|
||||
? pantryProductIds.contains(edit!.productId)
|
||||
: false;
|
||||
final alreadyInPantry =
|
||||
edit?.productId != null && edit?.destination == _Destination.pantry
|
||||
? pantryProductIds.contains(edit!.productId)
|
||||
: false;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||
@@ -1126,24 +1191,28 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
||||
Text(
|
||||
'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
||||
color: isMatched
|
||||
? Colors.green.shade700
|
||||
: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
matchedViaBadgeBuilder(item, theme),
|
||||
if (edit!.categorySource != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: edit!.categorySource == CategorySelectionSource.ai
|
||||
? Colors.green.shade50
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
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,
|
||||
color:
|
||||
edit!.categorySource == CategorySelectionSource.ai
|
||||
? Colors.green.shade300
|
||||
: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
@@ -1151,9 +1220,10 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
||||
? 'AI'
|
||||
: 'Manuell',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: edit!.categorySource == CategorySelectionSource.ai
|
||||
? Colors.green.shade800
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
color:
|
||||
edit!.categorySource == CategorySelectionSource.ai
|
||||
? Colors.green.shade800
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1210,33 +1280,39 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
||||
if (existingInv != null && canMergePreview) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700),
|
||||
Icon(Icons.kitchen_outlined,
|
||||
size: 12, color: Colors.blue.shade700),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + convertedPreviewQty).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: Colors.blue.shade700),
|
||||
),
|
||||
]),
|
||||
],
|
||||
if (existingInv != null && !canMergePreview) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
Icon(Icons.info_outline, size: 12, color: Colors.orange.shade700),
|
||||
Icon(Icons.info_outline,
|
||||
size: 12, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Finns i lager med annan enhet (${existingInv.unit}) - sparas som ny rad',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: Colors.orange.shade700),
|
||||
),
|
||||
]),
|
||||
],
|
||||
if (alreadyInPantry) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
Icon(Icons.inventory_2_outlined, size: 12, color: Colors.orange.shade700),
|
||||
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),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: Colors.orange.shade700),
|
||||
),
|
||||
]),
|
||||
],
|
||||
@@ -1253,19 +1329,31 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
||||
: (isSuggested ? Icons.help_outline : Icons.error_outline),
|
||||
color: hasProduct
|
||||
? Colors.green
|
||||
: (isSuggested ? Colors.orange : theme.colorScheme.tertiary),
|
||||
: (isSuggested
|
||||
? Colors.orange
|
||||
: theme.colorScheme.tertiary),
|
||||
size: 20,
|
||||
),
|
||||
if (hasProduct)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.drive_file_rename_outline, size: 18),
|
||||
icon: const Icon(
|
||||
Icons.drive_file_rename_outline,
|
||||
size: 18,
|
||||
semanticLabel: 'Spara alias',
|
||||
),
|
||||
onPressed: onAliasEditRequested,
|
||||
tooltip: 'Spara alias',
|
||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
padding: const EdgeInsets.all(4),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error),
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.error,
|
||||
semanticLabel: 'Ta bort rad',
|
||||
),
|
||||
onPressed: onDeleteRequested,
|
||||
tooltip: 'Ta bort rad',
|
||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
@@ -1315,33 +1403,33 @@ class _ReceiptPreviewDialog extends StatelessWidget {
|
||||
child: SelectableText.rich(
|
||||
TextSpan(
|
||||
children: items.isEmpty
|
||||
? [TextSpan(text: '(Inga rader)', style: theme.textTheme.bodySmall)]
|
||||
: items
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) {
|
||||
final item = entry.value;
|
||||
final lineNumber = entry.key + 1;
|
||||
final lineText = _formatReceiptLine(item);
|
||||
return TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$lineNumber. ',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
? [
|
||||
TextSpan(
|
||||
text: '(Inga rader)',
|
||||
style: theme.textTheme.bodySmall)
|
||||
]
|
||||
: items.asMap().entries.map((entry) {
|
||||
final item = entry.value;
|
||||
final lineNumber = entry.key + 1;
|
||||
final lineText = _formatReceiptLine(item);
|
||||
return TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$lineNumber. ',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
TextSpan(
|
||||
text: lineText,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: lineText,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
],
|
||||
);
|
||||
})
|
||||
.toList(),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
@@ -1379,4 +1467,3 @@ class _ReceiptPreviewDialog extends StatelessWidget {
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,9 @@ double _stepForUnit(String unit) {
|
||||
|
||||
/// Formats a step value for display: whole numbers without decimal,
|
||||
/// fractions with one decimal.
|
||||
String _fmtStep(double step) =>
|
||||
step == step.roundToDouble() ? step.toStringAsFixed(0) : step.toStringAsFixed(1);
|
||||
String _fmtStep(double step) => step == step.roundToDouble()
|
||||
? step.toStringAsFixed(0)
|
||||
: step.toStringAsFixed(1);
|
||||
|
||||
/// A [ListTile] wrapped in a swipe-to-adjust widget.
|
||||
///
|
||||
@@ -122,7 +123,8 @@ class _SwipeableInventoryTileState
|
||||
// Decrease: use consume endpoint so history is preserved.
|
||||
// Guard against going below zero.
|
||||
if (widget.item.quantity <= 0) return;
|
||||
final consume = step > widget.item.quantity ? widget.item.quantity : step;
|
||||
final consume =
|
||||
step > widget.item.quantity ? widget.item.quantity : step;
|
||||
await repo.consumeInventoryItem(
|
||||
widget.item.id,
|
||||
amountUsed: consume,
|
||||
@@ -402,32 +404,44 @@ class _TrailingActions extends ConsumerWidget {
|
||||
Tooltip(
|
||||
message: 'Konsumera',
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
icon: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
semanticLabel: 'Konsumera',
|
||||
),
|
||||
onPressed: () => context.push('/inventory/${item.id}/consume'),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Redigera',
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
icon: const Icon(
|
||||
Icons.edit_outlined,
|
||||
semanticLabel: 'Redigera',
|
||||
),
|
||||
onPressed: () => context.push('/inventory/${item.id}/edit'),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Flytta till baslager',
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.storefront_outlined),
|
||||
icon: const Icon(
|
||||
Icons.storefront_outlined,
|
||||
semanticLabel: 'Flytta till baslager',
|
||||
),
|
||||
onPressed: () async {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
await ref.read(inventoryRepositoryProvider).moveInventoryItemToPantry(
|
||||
await ref
|
||||
.read(inventoryRepositoryProvider)
|
||||
.moveInventoryItemToPantry(
|
||||
item.id,
|
||||
token: token,
|
||||
);
|
||||
ref.invalidate(inventoryProvider);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
buildCopyableErrorSnackBar(
|
||||
context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -449,7 +463,11 @@ class _DeleteButton extends ConsumerWidget {
|
||||
return Tooltip(
|
||||
message: 'Ta bort',
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
semanticLabel: 'Ta bort',
|
||||
),
|
||||
onPressed: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -477,7 +495,8 @@ class _DeleteButton extends ConsumerWidget {
|
||||
ref.invalidate(inventoryProvider);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
buildCopyableErrorSnackBar(
|
||||
context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -486,4 +505,3 @@ class _DeleteButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user