chore(import): improve error handling and add flyer integration
- 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:
@@ -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>[];
|
||||
|
||||
Reference in New Issue
Block a user