feat(import): implement recipe import functionality with file and URL support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-04-22 21:31:25 +02:00
parent 8ebf119d39
commit 81117fbcb7
11 changed files with 617 additions and 13 deletions
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'import_repository.dart';
final importRepositoryProvider = Provider<ImportRepository>(
(_) => ImportRepository(),
);
@@ -0,0 +1,115 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../core/api/api_exception.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',
);
/// 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 {
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),
);
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);
return QuickImportResult.fromJson(_parseResponse(response));
}
/// Import a recipe from a URL.
Future<QuickImportResult> importUrl({
required String url,
String? token,
}) async {
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: () => throw ApiException(
type: ApiErrorType.network,
message: 'Importen tog för lång tid. Försök igen.',
),
);
return QuickImportResult.fromJson(_parseResponse(response));
}
Map<String, dynamic> _parseResponse(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
Map<String, dynamic>? body;
try {
body = jsonDecode(response.body) as Map<String, dynamic>;
} catch (_) {}
final message = body?['message'] as String? ??
body?['error'] as String? ??
'Import misslyckades (${response.statusCode}).';
if (response.statusCode == 401) {
throw ApiException(type: ApiErrorType.unauthorized, statusCode: 401);
}
if (response.statusCode == 403) {
throw ApiException(type: ApiErrorType.forbidden, statusCode: 403);
}
if (response.statusCode >= 500) {
throw ApiException(
type: ApiErrorType.server,
statusCode: response.statusCode,
message: message);
}
throw ApiException(
type: ApiErrorType.unknown,
statusCode: response.statusCode,
message: message);
}
}
@@ -0,0 +1,19 @@
/// Result from `POST /api/quick-import`.
class QuickImportResult {
final String markdown;
final String source; // 'ica' | 'pdf' | 'image' | 'other'
final String? imageUrl;
const QuickImportResult({
required this.markdown,
required this.source,
this.imageUrl,
});
factory QuickImportResult.fromJson(Map<String, dynamic> json) =>
QuickImportResult(
markdown: json['markdown'] as String? ?? '',
source: json['source'] as String? ?? 'other',
imageUrl: json['imageUrl'] as String?,
);
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'recipe_import_tab.dart';
/// Main import screen with tabs: Recept | Kvitto.
///
/// Fas 6a: Recept-fliken är implementerad.
/// Fas 6b: Kvitto-fliken läggs till i ett senare steg.
class ImportScreen extends StatefulWidget {
const ImportScreen({super.key});
@override
State<ImportScreen> createState() => _ImportScreenState();
}
class _ImportScreenState extends State<ImportScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Importera'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.restaurant_menu_outlined), text: 'Recept'),
Tab(icon: Icon(Icons.receipt_long_outlined), text: 'Kvitto'),
],
),
),
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,
),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,242 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart';
/// Accepted MIME types / extensions for recipe file import.
const _allowedExtensions = ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp'];
/// Tab for importing a recipe via file upload or URL.
///
/// On success navigates to `/recipes/create` with the parsed markdown
/// passed as GoRouter `extra`.
class RecipeImportTab extends ConsumerStatefulWidget {
const RecipeImportTab({super.key});
@override
ConsumerState<RecipeImportTab> createState() => _RecipeImportTabState();
}
class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
// Shared state
bool _isLoading = false;
String? _error;
// File mode
PlatformFile? _pickedFile;
// URL mode
_Method _method = _Method.file;
final _urlCtrl = TextEditingController();
@override
void dispose() {
_urlCtrl.dispose();
super.dispose();
}
// ── File picker ──────────────────────────────────────────────────────────
Future<void> _pickFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: _allowedExtensions,
withData: true, // needed on Flutter web to get bytes
);
if (result == null || result.files.isEmpty) return;
setState(() {
_pickedFile = result.files.first;
_error = null;
});
}
// ── Submit ───────────────────────────────────────────────────────────────
Future<void> _submit() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final result = _method == _Method.file
? await repo.importFile(
bytes: _pickedFile!.bytes!,
filename: _pickedFile!.name,
token: token,
)
: await repo.importUrl(
url: _urlCtrl.text.trim(),
token: token,
);
if (!mounted) return;
// Pass markdown as GoRouter extra — CreateRecipeScreen picks it up.
context.push('/recipes/create', extra: result.markdown);
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
bool get _canSubmit {
if (_isLoading) return false;
if (_method == _Method.file) return _pickedFile?.bytes != null;
return _urlCtrl.text.trim().isNotEmpty;
}
// ── Build ────────────────────────────────────────────────────────────────
@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 en PDF eller bild, eller ange en receptlänk — '
'receptet importeras och öppnas direkt i redigeringsläget.',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 20),
// ── Metodväljare ────────────────────────────────────────────────
SegmentedButton<_Method>(
segments: const [
ButtonSegment(
value: _Method.file,
label: Text('Fil / PDF'),
icon: Icon(Icons.upload_file_outlined),
),
ButtonSegment(
value: _Method.url,
label: Text('Länk'),
icon: Icon(Icons.link),
),
],
selected: {_method},
onSelectionChanged: (s) =>
setState(() => _method = s.first),
),
const SizedBox(height: 24),
// ── Filläge ─────────────────────────────────────────────────────
if (_method == _Method.file) ...[
OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file),
label: Text(
_pickedFile == null
? 'Välj fil (PDF, PNG, JPG, WEBP, BMP)'
: _pickedFile!.name,
),
),
if (_pickedFile != null) ...[
const SizedBox(height: 8),
Text(
'${(_pickedFile!.size / 1024).round()} KB',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.outline),
),
],
],
// ── URL-läge ────────────────────────────────────────────────────
if (_method == _Method.url) ...[
TextField(
controller: _urlCtrl,
keyboardType: TextInputType.url,
autofocus: true,
enabled: !_isLoading,
decoration: const InputDecoration(
labelText: 'Receptlänk',
hintText: 'https://exempel.se/recept/...',
prefixIcon: Icon(Icons.link),
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
onSubmitted: (_) {
if (_canSubmit) _submit();
},
),
],
const SizedBox(height: 24),
// ── Laddningsindikator ───────────────────────────────────────────
if (_isLoading) ...[
const LinearProgressIndicator(),
const SizedBox(height: 8),
Text(
'Tolkar receptet — detta kan ta upp till en minut...',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 16),
],
// ── Felmeddelande ───────────────────────────────────────────────
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),
],
// ── Knapp ───────────────────────────────────────────────────────
FilledButton.icon(
onPressed: _canSubmit ? _submit : null,
icon: const Icon(Icons.auto_awesome_outlined),
label: Text(_method == _Method.file
? 'Importera fil'
: 'Importera från länk'),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 12),
// ── Alternativ ──────────────────────────────────────────────────
TextButton.icon(
onPressed: () => context.push('/recipes/create'),
icon: const Icon(Icons.edit_outlined),
label: const Text('Skriv in recept istället'),
),
],
),
);
}
}
enum _Method { file, url }