feat(web): improve web build configuration and accessibility
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 14m6s
Test Suite / flutter-quality (push) Failing after 4m44s

- 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:
Nils-Johan Gynther
2026-05-23 18:04:27 +02:00
parent 30d27d6b8a
commit 69bcc3e342
16 changed files with 1847 additions and 301 deletions
@@ -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 {
);
}
}