feat(import): implement recipe import functionality with file and URL support
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user