chore(import): improve error handling and add flyer integration
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 3m41s
Test Suite / flutter-quality (push) Successful in 2m3s

- Replace BadRequestException with UnauthorizedException for authentication failures in flyer-import and flyer-selection controllers
- Add bulk selection endpoint in FlyerSelectionController for creating multiple selections in one request
- Update FlyerSelectionModule to include new FlyerSelectionMatcherService and FlyerSelectionSyncController
- Extend FlyerSelectionService with createMany method for bulk operations
- Add new DTOs for bulk selection and receipt matching functionality
- Update ReceiptImportService to accept FlyerSelectionService dependency and track successful rows
- Extend SaveReceiptResponse with flyerAutoSync field for receipt-to-flyer matching results
- Add new API paths for flyer import and selection endpoints
- Update Flutter UI to include Flyer import tab and adjust tab controller length
- Add new domain models and repository methods for flyer import functionality
- Update test files to include new FlyerSelectionService dependency
- Modify .kilo plan documentation to reflect current system architecture
This commit is contained in:
Nils-Johan Gynther
2026-05-18 22:51:27 +02:00
parent 24a96c3da1
commit d5f903db98
26 changed files with 1359 additions and 247 deletions
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'import_repository.dart';
import 'import_repository.dart';
final importRepositoryProvider = Provider<ImportRepository>(
(_) => ImportRepository(),
);
final importRepositoryProvider = Provider<ImportRepository>(
(_) => ImportRepository(),
);
@@ -5,10 +5,11 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'dart:developer' as developer;
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_exception.dart';
import '../domain/help_text_content.dart';
import '../domain/quick_import_result.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_exception.dart';
import '../domain/flyer_import_result.dart';
import '../domain/help_text_content.dart';
import '../domain/quick_import_result.dart';
/// Handles communication with the quick-import API endpoint.
///
@@ -60,7 +61,7 @@ class ImportRepository {
/// Upload a receipt file for parsing (Fas 6b).
/// Returns a list of parsed receipt items.
Future<List<ParsedReceiptItem>> importReceiptFile({
Future<List<ParsedReceiptItem>> importReceiptFile({
required Uint8List bytes,
required String filename,
String? token,
@@ -142,7 +143,83 @@ class ImportRepository {
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
}
Future<FlyerImportResult> importFlyerFile({
required Uint8List bytes,
required String filename,
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.parse}');
final request = http.MultipartRequest('POST', uri);
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
}
request.files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
final streamed = await _client.send(request).timeout(
const Duration(seconds: 120),
onTimeout: () {
throw ApiException(
type: ApiErrorType.network,
message: 'Flyerimporten tog för lång tid. Försök igen.',
);
},
);
final response = await http.Response.fromStream(streamed);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid flyerimport: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! Map<String, dynamic>) {
throw ApiException(
type: ApiErrorType.unknown,
message: 'Felaktigt svar från flyerimport.',
);
}
return FlyerImportResult.fromJson(parsed);
}
Future<List<Map<String, dynamic>>> createFlyerSelectionsBulk({
required int sessionId,
required List<Map<String, dynamic>> items,
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${FlyerSelectionApiPaths.bulkBySession(sessionId)}');
final response = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
'items': items,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte skapa flyer-selections: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! List) return const [];
return parsed.cast<Map<String, dynamic>>();
}
/// Upload a file (PDF or image) for recipe extraction.
///
@@ -335,7 +412,7 @@ class ImportRepository {
);
}
final result = _parseResponse(response) as Map<String, dynamic>;
final result = _parseResponse(response) as Map<String, dynamic>;
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
return result;
} catch (e) {
@@ -0,0 +1,43 @@
class FlyerImportItem {
final int? flyerItemId;
final String rawName;
final String normalizedName;
final String? category;
final double? price;
final String? priceUnit;
final String? offerText;
final int? matchedProductId;
final String? matchedProductName;
final String? matchedVia;
final double? matchConfidence;
FlyerImportItem({
required this.flyerItemId,
required this.rawName,
required this.normalizedName,
this.category,
this.price,
this.priceUnit,
this.offerText,
this.matchedProductId,
this.matchedProductName,
this.matchedVia,
this.matchConfidence,
});
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
return FlyerImportItem(
flyerItemId: (json['flyerItemId'] as num?)?.toInt(),
rawName: json['rawName'] as String? ?? '',
normalizedName: json['normalizedName'] as String? ?? '',
category: json['category'] as String?,
price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?,
offerText: json['offerText'] as String?,
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?,
matchedVia: json['matchedVia'] as String?,
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
);
}
}
@@ -0,0 +1,29 @@
import 'flyer_import_item.dart';
class FlyerImportResult {
final int? sessionId;
final List<FlyerImportItem> items;
final List<String> warnings;
FlyerImportResult({
required this.sessionId,
required this.items,
required this.warnings,
});
factory FlyerImportResult.fromJson(Map<String, dynamic> json) {
final rawItems = (json['items'] as List?) ?? const [];
final warnings = (json['warnings'] as List?)
?.map((warning) => warning.toString())
.toList() ??
const <String>[];
return FlyerImportResult(
sessionId: (json['sessionId'] as num?)?.toInt(),
items: rawItems
.map((item) => FlyerImportItem.fromJson(item as Map<String, dynamic>))
.toList(),
warnings: warnings,
);
}
}
@@ -0,0 +1,196 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart';
import '../domain/flyer_import_item.dart';
import '../domain/flyer_import_result.dart';
import 'error_dialog.dart';
class FlyerImportTab extends ConsumerStatefulWidget {
const FlyerImportTab({super.key});
@override
ConsumerState<FlyerImportTab> createState() => _FlyerImportTabState();
}
class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
bool _isLoading = false;
bool _isSaving = false;
PlatformFile? _pickedFile;
FlyerImportResult? _result;
final Map<int, bool> _selected = {};
Future<void> _pickFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'txt'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
setState(() => _pickedFile = result.files.first);
}
Future<void> _parseFlyer() async {
final file = _pickedFile;
if (file?.bytes == null) return;
setState(() => _isLoading = true);
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final parsed = await repo.importFlyerFile(
bytes: file!.bytes!,
filename: file.name,
token: token,
);
if (!mounted) return;
final selected = <int, bool>{};
for (var i = 0; i < parsed.items.length; i++) {
selected[i] = parsed.items[i].matchedProductId != null;
}
setState(() {
_result = parsed;
_selected
..clear()
..addAll(selected);
});
} catch (e) {
if (mounted) showErrorDialog(context, 'Flyerimport misslyckades: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _planSelected() async {
final result = _result;
if (result?.sessionId == null) return;
final itemsToSave = <Map<String, dynamic>>[];
for (var i = 0; i < result!.items.length; i++) {
final item = result.items[i];
final isSelected = _selected[i] == true;
if (!isSelected || item.flyerItemId == null) continue;
itemsToSave.add({
'itemId': item.flyerItemId,
'plannedQuantity': 1,
'plannedUnit': item.priceUnit,
'priority': 'normal',
});
}
if (itemsToSave.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Markera minst en rad att planera.')),
);
return;
}
setState(() => _isSaving = true);
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final saved = await repo.createFlyerSelectionsBulk(
sessionId: result.sessionId!,
items: itemsToSave,
token: token,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${saved.length} varor planerade.')),
);
} catch (e) {
if (mounted) showErrorDialog(context, 'Kunde inte planera varor: $e');
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final items = _result?.items ?? const <FlyerImportItem>[];
final selectedCount = _selected.values.where((value) => value).length;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ladda upp flyer (PDF/txt), granska rader och planera inköp med ett klick.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file),
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: (!_isLoading && _pickedFile?.bytes != null) ? _parseFlyer : null,
icon: const Icon(Icons.auto_awesome),
label: const Text('Importera flyer'),
),
if (_isLoading) ...[
const SizedBox(height: 12),
const LinearProgressIndicator(),
],
if (items.isNotEmpty) ...[
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${items.length} rader hittades', style: theme.textTheme.titleSmall),
TextButton(
onPressed: () {
final target = selectedCount < items.length;
setState(() {
for (var i = 0; i < items.length; i++) {
_selected[i] = target;
}
});
},
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;
return CheckboxListTile(
value: _selected[index] ?? false,
onChanged: (value) => setState(() => _selected[index] = value ?? false),
title: Text(item.rawName),
subtitle: Text([
if (item.offerText != null && item.offerText!.isNotEmpty) item.offerText!,
if (item.matchedProductName != null) 'Match: ${item.matchedProductName}',
].join(' · ')),
controlAffinity: ListTileControlAffinity.leading,
);
}),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: (_isSaving || selectedCount == 0) ? null : _planSelected,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.playlist_add_check),
label: Text('Planera $selectedCount markerade'),
),
),
],
],
),
);
}
}
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'recipe_import_tab.dart';
import 'receipt_import_tab.dart';
import 'flyer_import_tab.dart';
import 'recipe_import_tab.dart';
import 'receipt_import_tab.dart';
/// Main import screen with tabs: Recept | Kvitto.
/// Main import screen with tabs: Recept | Kvitto | Flyer.
///
/// Fas 6a: Recept-fliken är implementerad.
/// Fas 6b: Kvitto-fliken läggs till i ett senare steg.
@@ -18,10 +19,11 @@ class _ImportScreenState extends State<ImportScreen> {
@override
Widget build(BuildContext context) {
return const TabBarView(
children: [
RecipeImportTab(),
ReceiptImportTab(),
],
);
}
}
children: [
RecipeImportTab(),
ReceiptImportTab(),
FlyerImportTab(),
],
);
}
}
@@ -708,8 +708,9 @@ 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 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',
@@ -717,8 +718,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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 (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>[];