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,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 }