feat: Add receipt import functionality with file upload and parsing
- Implemented receipt file upload in ImportRepository with multipart request handling. - Created ParsedReceiptItem model for parsed receipt data. - Added ReceiptImportTab for user interface to upload and review receipts. - Updated ImportScreen to include the new ReceiptImportTab alongside RecipeImportTab. - Introduced flutter_bootstrap.js and index.html for web app initialization. - Added wimp.wasm and flutter.js for enhanced web performance and capabilities.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import '../domain/parsed_receipt_item.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -12,6 +13,34 @@ import '../domain/quick_import_result.dart';
|
||||
/// • [importFile] — multipart upload (PDF / image bytes, max 10 MB).
|
||||
/// • [importUrl] — JSON body with `{ input: url }`.
|
||||
class ImportRepository {
|
||||
/// 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 {
|
||||
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),
|
||||
);
|
||||
final streamed = await _client.send(request).timeout(
|
||||
const Duration(seconds: 120),
|
||||
onTimeout: () => throw ApiException(
|
||||
type: ApiErrorType.network,
|
||||
message: 'Importen tog för lång tid. Försök igen.',
|
||||
),
|
||||
);
|
||||
final response = await http.Response.fromStream(streamed);
|
||||
final parsed = _parseResponse(response);
|
||||
final items = (parsed['items'] as List?) ?? parsed as List?;
|
||||
if (items == null) throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
|
||||
return items.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
final http.Client _client;
|
||||
final String _baseUrl;
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Model for a parsed receipt item from the receipt import API.
|
||||
class ParsedReceiptItem {
|
||||
final String rawName;
|
||||
final double? quantity;
|
||||
final String? unit;
|
||||
final String? suggestedProductId;
|
||||
final String? suggestedProductName;
|
||||
final String? categorySuggestion;
|
||||
|
||||
ParsedReceiptItem({
|
||||
required this.rawName,
|
||||
this.quantity,
|
||||
this.unit,
|
||||
this.suggestedProductId,
|
||||
this.suggestedProductName,
|
||||
this.categorySuggestion,
|
||||
});
|
||||
|
||||
factory ParsedReceiptItem.fromJson(Map<String, dynamic> json) => ParsedReceiptItem(
|
||||
rawName: json['rawName'] as String,
|
||||
quantity: (json['quantity'] as num?)?.toDouble(),
|
||||
unit: json['unit'] as String?,
|
||||
suggestedProductId: json['suggestedProductId'] as String?,
|
||||
suggestedProductName: json['suggestedProductName'] as String?,
|
||||
categorySuggestion: json['categorySuggestion'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'recipe_import_tab.dart';
|
||||
import 'receipt_import_tab.dart';
|
||||
|
||||
/// Main import screen with tabs: Recept | Kvitto.
|
||||
///
|
||||
@@ -44,38 +45,9 @@ class _ImportScreenState extends State<ImportScreen>
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
const RecipeImportTab(),
|
||||
// Fas 6b — placeholder tills kvitto-flödet är implementerat.
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.construction_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Kvittoimport kommer snart',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Fotografera eller ladda upp ett kvitto — varorna '
|
||||
'läggs till i ditt inventarie.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
children: const [
|
||||
RecipeImportTab(),
|
||||
ReceiptImportTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/import_providers.dart';
|
||||
import '../domain/parsed_receipt_item.dart';
|
||||
|
||||
class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||
const ReceiptImportTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReceiptImportTab> createState() => _ReceiptImportTabState();
|
||||
}
|
||||
|
||||
class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
PlatformFile? _pickedFile;
|
||||
List<ParsedReceiptItem>? _items;
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
setState(() {
|
||||
_pickedFile = result.files.first;
|
||||
_error = null;
|
||||
_items = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_items = null;
|
||||
});
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final repo = ref.read(importRepositoryProvider);
|
||||
final items = await repo.importReceiptFile(
|
||||
bytes: _pickedFile!.bytes!,
|
||||
filename: _pickedFile!.name,
|
||||
token: token,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _items = items);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canSubmit => !_isLoading && _pickedFile?.bytes != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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)),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading ? null : _pickFile,
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: Text(_pickedFile == null ? 'Välj kvittofil' : _pickedFile!.name),
|
||||
),
|
||||
if (_pickedFile != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('${(_pickedFile!.size / 1024).round()} KB', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
if (_isLoading) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
Text('Tolkar kvittot — detta kan ta upp till en minut...', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.onErrorContainer, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_error!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onErrorContainer))),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
icon: const Icon(Icons.receipt_long_outlined),
|
||||
label: const Text('Importera kvitto'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_items != null) ...[
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
Text('Granska rader:', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
..._items!.map((item) => ListTile(
|
||||
leading: const Icon(Icons.shopping_cart_outlined),
|
||||
title: Text(item.rawName),
|
||||
subtitle: Text('${item.quantity ?? ''} ${item.unit ?? ''}'),
|
||||
trailing: Text(item.suggestedProductName ?? '', style: theme.textTheme.bodySmall),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user