Update flyerimport. flutter timeout 300 sek
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 4m58s
Test Suite / flutter-quality (push) Failing after 1m41s

This commit is contained in:
Nils-Johan Gynther
2026-05-19 20:53:39 +02:00
parent 33190bd8e0
commit 8b8f8b7b6f
7 changed files with 523 additions and 411 deletions
@@ -1,148 +1,148 @@
import '../domain/parsed_receipt_item.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'dart:developer' as developer;
import '../domain/parsed_receipt_item.dart';
import 'dart:convert';
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/flyer_import_result.dart';
import '../domain/help_text_content.dart';
import '../domain/quick_import_result.dart';
/// Handles communication with the quick-import API endpoint.
///
/// Two modes:
/// • [importFile] — multipart upload (PDF / image bytes, max 10 MB).
/// • [importUrl] — JSON body with `{ input: url }`.
class ImportRepository {
final http.Client _client;
final String _baseUrl;
ImportRepository({http.Client? client})
: _client = client ?? http.Client(),
_baseUrl = const String.fromEnvironment(
'API_BASE_URL',
defaultValue: '/api',
);
Future<HelpTextContent> fetchHelpTextByKey(
String key, {
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}');
final response = await _client.get(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte hämta hjälptext: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! Map<String, dynamic>) {
throw ApiException(
type: ApiErrorType.unknown,
message: 'Felaktigt svar vid hämtning av hjälptext.',
);
}
return HelpTextContent.fromJson(parsed);
}
/// Upload a receipt file for parsing (Fas 6b).
/// Returns a list of parsed receipt items.
/// Handles communication with the quick-import API endpoint.
///
/// Two modes:
/// • [importFile] — multipart upload (PDF / image bytes, max 10 MB).
/// • [importUrl] — JSON body with `{ input: url }`.
class ImportRepository {
final http.Client _client;
final String _baseUrl;
ImportRepository({http.Client? client})
: _client = client ?? http.Client(),
_baseUrl = const String.fromEnvironment(
'API_BASE_URL',
defaultValue: '/api',
);
Future<HelpTextContent> fetchHelpTextByKey(
String key, {
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}');
final response = await _client.get(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte hämta hjälptext: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! Map<String, dynamic>) {
throw ApiException(
type: ApiErrorType.unknown,
message: 'Felaktigt svar vid hämtning av hjälptext.',
);
}
return HelpTextContent.fromJson(parsed);
}
/// Upload a receipt file for parsing (Fas 6b).
/// Returns a list of parsed receipt items.
Future<List<ParsedReceiptItem>> importReceiptFile({
required Uint8List bytes,
required String filename,
String? token,
}) async {
try {
developer.log('Starting receipt import for file: $filename', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/receipt-import');
final request = http.MultipartRequest('POST', uri);
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
}
request.files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
final streamed = await _client.send(request).timeout(
const Duration(seconds: 120),
onTimeout: () {
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Importen tog för lång tid. Försök igen.',
);
},
);
final response = await http.Response.fromStream(streamed);
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode < 200 || response.statusCode >= 300) {
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid import: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
developer.log('Response parsed, keys: ${parsed is Map ? parsed.keys.toList() : "list[${(parsed as List?)?.length}]"}', name: 'ImportRepository');
// Backend returns ParsedReceiptItem[] directly as a JSON array
if (parsed is List) {
final items = parsed.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
developer.log('Successfully parsed ${items.length} items from array', name: 'ImportRepository');
return items;
}
if (parsed is Map<String, dynamic>) {
// Wrapped format: { items: [...] }
if (parsed.containsKey('items')) {
final items = (parsed['items'] as List?)?.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
if (items != null) {
developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository');
return items;
}
}
// Markdown-based receipt fallback — parse lines into items
if (parsed.containsKey('markdown')) {
developer.log('Got markdown receipt, parsing lines into items', name: 'ImportRepository');
final lines = (parsed['markdown'] as String).split('\n');
final items = lines
.where((l) => l.trim().isNotEmpty && !l.startsWith('#') && !l.startsWith('=') && !l.startsWith('-'))
.map((l) => ParsedReceiptItem(rawName: l.trim()))
.toList();
developer.log('Extracted ${items.length} lines as items', name: 'ImportRepository');
return items;
}
}
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
} catch (e) {
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
rethrow;
}
required Uint8List bytes,
required String filename,
String? token,
}) async {
try {
developer.log('Starting receipt import for file: $filename', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/receipt-import');
final request = http.MultipartRequest('POST', uri);
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
}
request.files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
final streamed = await _client.send(request).timeout(
const Duration(seconds: 120),
onTimeout: () {
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Importen tog för lång tid. Försök igen.',
);
},
);
final response = await http.Response.fromStream(streamed);
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode < 200 || response.statusCode >= 300) {
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid import: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
developer.log('Response parsed, keys: ${parsed is Map ? parsed.keys.toList() : "list[${(parsed as List?)?.length}]"}', name: 'ImportRepository');
// Backend returns ParsedReceiptItem[] directly as a JSON array
if (parsed is List) {
final items = parsed.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
developer.log('Successfully parsed ${items.length} items from array', name: 'ImportRepository');
return items;
}
if (parsed is Map<String, dynamic>) {
// Wrapped format: { items: [...] }
if (parsed.containsKey('items')) {
final items = (parsed['items'] as List?)?.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
if (items != null) {
developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository');
return items;
}
}
// Markdown-based receipt fallback — parse lines into items
if (parsed.containsKey('markdown')) {
developer.log('Got markdown receipt, parsing lines into items', name: 'ImportRepository');
final lines = (parsed['markdown'] as String).split('\n');
final items = lines
.where((l) => l.trim().isNotEmpty && !l.startsWith('#') && !l.startsWith('=') && !l.startsWith('-'))
.map((l) => ParsedReceiptItem(rawName: l.trim()))
.toList();
developer.log('Extracted ${items.length} lines as items', name: 'ImportRepository');
return items;
}
}
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
} catch (e) {
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
Future<FlyerImportResult> importFlyerFile({
@@ -162,7 +162,7 @@ class ImportRepository {
);
final streamed = await _client.send(request).timeout(
const Duration(seconds: 120),
const Duration(seconds: 300),
onTimeout: () {
throw ApiException(
type: ApiErrorType.network,
@@ -220,225 +220,225 @@ class ImportRepository {
if (parsed is! List) return const [];
return parsed.cast<Map<String, dynamic>>();
}
/// Upload a file (PDF or image) for recipe extraction.
///
/// [bytes] — raw file bytes from file_picker.
/// [filename] — original filename (used for MIME detection on the server).
/// [token] — JWT bearer token.
Future<QuickImportResult> importFile({
required Uint8List bytes,
required String filename,
String? token,
}) async {
try {
developer.log('Starting file import for file: $filename', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/quick-import');
final request = http.MultipartRequest('POST', uri);
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
}
request.files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
final streamed = await _client.send(request).timeout(
const Duration(seconds: 120),
onTimeout: () {
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Importen tog för lång tid. Försök igen.',
);
},
);
final response = await http.Response.fromStream(streamed);
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode != 200) {
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid import: ${response.body}',
statusCode: response.statusCode,
);
}
developer.log('Successfully imported file', name: 'ImportRepository');
return QuickImportResult.fromJson(_parseResponse(response));
} catch (e) {
developer.log('Exception during file import: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
/// Import a recipe from a URL.
Future<QuickImportResult> importUrl({
required String url,
String? token,
}) async {
try {
developer.log('Starting URL import for: $url', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/quick-import');
final response = await _client
.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({'input': url}),
)
.timeout(
const Duration(seconds: 120),
onTimeout: () {
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Importen tog för lång tid. Försök igen.',
);
},
);
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode != 200) {
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid import: ${response.body}',
statusCode: response.statusCode,
);
}
developer.log('Successfully imported URL', name: 'ImportRepository');
return QuickImportResult.fromJson(_parseResponse(response));
} catch (e) {
developer.log('Exception during URL import: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
Future<void> upsertUnitMapping({
required int productId,
required String originalUnit,
required String preferredUnit,
String? token,
}) async {
final normalizedOriginalUnit = originalUnit.trim().toLowerCase();
final normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
if (normalizedOriginalUnit.isEmpty || normalizedPreferredUnit.isEmpty) {
return;
}
if (normalizedOriginalUnit == normalizedPreferredUnit) {
return;
}
final uri = Uri.parse('$_baseUrl${ReceiptImportApiPaths.unitMappings}');
final response = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
'productId': productId,
'originalUnit': normalizedOriginalUnit,
'preferredUnit': normalizedPreferredUnit,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte spara enhetsmappning: ${response.body}',
statusCode: response.statusCode,
);
}
}
/// Save receipt items in a single atomic transaction.
///
/// This endpoint handles:
/// - Creating/validating products
/// - Creating/merging inventory items
/// - Adding to pantry
/// - Learning aliases
/// - Learning unit mappings
Future<Map<String, dynamic>> saveReceipt({
required List<Map<String, dynamic>> items,
String? token,
}) async {
try {
developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/receipt-import/save');
final response = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
'items': items,
}),
).timeout(
const Duration(seconds: 60),
onTimeout: () {
developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Sparandet tok för lång tid. Försök igen.',
);
},
);
developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode < 200 || response.statusCode >= 300) {
developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid sparande: ${response.body}',
statusCode: response.statusCode,
);
}
/// Upload a file (PDF or image) for recipe extraction.
///
/// [bytes] — raw file bytes from file_picker.
/// [filename] — original filename (used for MIME detection on the server).
/// [token] — JWT bearer token.
Future<QuickImportResult> importFile({
required Uint8List bytes,
required String filename,
String? token,
}) async {
try {
developer.log('Starting file import for file: $filename', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/quick-import');
final request = http.MultipartRequest('POST', uri);
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
}
request.files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
final streamed = await _client.send(request).timeout(
const Duration(seconds: 120),
onTimeout: () {
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Importen tog för lång tid. Försök igen.',
);
},
);
final response = await http.Response.fromStream(streamed);
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode != 200) {
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid import: ${response.body}',
statusCode: response.statusCode,
);
}
developer.log('Successfully imported file', name: 'ImportRepository');
return QuickImportResult.fromJson(_parseResponse(response));
} catch (e) {
developer.log('Exception during file import: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
/// Import a recipe from a URL.
Future<QuickImportResult> importUrl({
required String url,
String? token,
}) async {
try {
developer.log('Starting URL import for: $url', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/quick-import');
final response = await _client
.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({'input': url}),
)
.timeout(
const Duration(seconds: 120),
onTimeout: () {
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Importen tog för lång tid. Försök igen.',
);
},
);
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode != 200) {
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid import: ${response.body}',
statusCode: response.statusCode,
);
}
developer.log('Successfully imported URL', name: 'ImportRepository');
return QuickImportResult.fromJson(_parseResponse(response));
} catch (e) {
developer.log('Exception during URL import: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
Future<void> upsertUnitMapping({
required int productId,
required String originalUnit,
required String preferredUnit,
String? token,
}) async {
final normalizedOriginalUnit = originalUnit.trim().toLowerCase();
final normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
if (normalizedOriginalUnit.isEmpty || normalizedPreferredUnit.isEmpty) {
return;
}
if (normalizedOriginalUnit == normalizedPreferredUnit) {
return;
}
final uri = Uri.parse('$_baseUrl${ReceiptImportApiPaths.unitMappings}');
final response = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
'productId': productId,
'originalUnit': normalizedOriginalUnit,
'preferredUnit': normalizedPreferredUnit,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte spara enhetsmappning: ${response.body}',
statusCode: response.statusCode,
);
}
}
/// Save receipt items in a single atomic transaction.
///
/// This endpoint handles:
/// - Creating/validating products
/// - Creating/merging inventory items
/// - Adding to pantry
/// - Learning aliases
/// - Learning unit mappings
Future<Map<String, dynamic>> saveReceipt({
required List<Map<String, dynamic>> items,
String? token,
}) async {
try {
developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository');
final uri = Uri.parse('$_baseUrl/receipt-import/save');
final response = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
'items': items,
}),
).timeout(
const Duration(seconds: 60),
onTimeout: () {
developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout');
throw ApiException(
type: ApiErrorType.network,
message: 'Sparandet tok för lång tid. Försök igen.',
);
},
);
developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository');
if (response.statusCode < 200 || response.statusCode >= 300) {
developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Fel vid sparande: ${response.body}',
statusCode: response.statusCode,
);
}
final result = _parseResponse(response) as Map<String, dynamic>;
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
return result;
} catch (e) {
developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
/// Helper method to map HTTP status codes to [ApiErrorType].
ApiErrorType _mapStatusCodeToErrorType(int statusCode) {
if (statusCode == 401) return ApiErrorType.unauthorized;
if (statusCode == 403) return ApiErrorType.forbidden;
if (statusCode >= 500) return ApiErrorType.server;
return ApiErrorType.unknown;
}
/// Parse the HTTP response and handle potential errors.
dynamic _parseResponse(http.Response response) {
try {
return jsonDecode(response.body);
} catch (e) {
developer.log('Failed to parse response: ${response.body}', name: 'ImportRepository', error: e);
throw ApiException(
type: ApiErrorType.unknown,
message: 'Felaktigt svar från servern: ${response.body}',
);
}
}
}
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
return result;
} catch (e) {
developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e);
rethrow;
}
}
/// Helper method to map HTTP status codes to [ApiErrorType].
ApiErrorType _mapStatusCodeToErrorType(int statusCode) {
if (statusCode == 401) return ApiErrorType.unauthorized;
if (statusCode == 403) return ApiErrorType.forbidden;
if (statusCode >= 500) return ApiErrorType.server;
return ApiErrorType.unknown;
}
/// Parse the HTTP response and handle potential errors.
dynamic _parseResponse(http.Response response) {
try {
return jsonDecode(response.body);
} catch (e) {
developer.log('Failed to parse response: ${response.body}', name: 'ImportRepository', error: e);
throw ApiException(
type: ApiErrorType.unknown,
message: 'Felaktigt svar från servern: ${response.body}',
);
}
}
}