From 81117fbcb7398ad20356da57f46f15f580d4d5bb Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 21:31:25 +0200 Subject: [PATCH] feat(import): implement recipe import functionality with file and URL support Co-authored-by: Copilot --- flutter/lib/core/router/app_router.dart | 10 +- flutter/lib/core/ui/app_shell.dart | 6 + .../import/data/import_providers.dart | 7 + .../import/data/import_repository.dart | 115 +++++++++ .../import/domain/quick_import_result.dart | 19 ++ .../import/presentation/import_screen.dart | 83 ++++++ .../presentation/recipe_import_tab.dart | 242 ++++++++++++++++++ .../swipeable_inventory_tile.dart | 54 +++- .../presentation/create_recipe_screen.dart | 15 +- flutter/pubspec.yaml | 1 + next_steps_flutter.md | 78 +++++- 11 files changed, 617 insertions(+), 13 deletions(-) create mode 100644 flutter/lib/features/import/data/import_providers.dart create mode 100644 flutter/lib/features/import/data/import_repository.dart create mode 100644 flutter/lib/features/import/domain/quick_import_result.dart create mode 100644 flutter/lib/features/import/presentation/import_screen.dart create mode 100644 flutter/lib/features/import/presentation/recipe_import_tab.dart diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index e7f23e6e..b3bf9b73 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -19,6 +19,7 @@ import '../../features/inventory/presentation/consume_inventory_screen.dart'; import '../../features/inventory/presentation/consumption_history_screen.dart'; import '../../features/meal_plan/presentation/meal_plan_screen.dart'; import '../../features/pantry/presentation/pantry_screen.dart'; +import '../../features/import/presentation/import_screen.dart'; final appRouterProvider = Provider((ref) { final authState = ref.watch(authStateProvider); @@ -66,7 +67,10 @@ final appRouterProvider = Provider((ref) { // /recipes/create must be listed before /recipes/:id to avoid conflict. GoRoute( path: '/recipes/create', - builder: (context, state) => const CreateRecipeScreen(), + builder: (context, state) { + final initialMarkdown = state.extra as String?; + return CreateRecipeScreen(initialMarkdown: initialMarkdown); + }, ), GoRoute( path: '/recipes/:id', @@ -168,6 +172,10 @@ final appRouterProvider = Provider((ref) { path: '/baslager', builder: (context, state) => const PantryScreen(), ), + GoRoute( + path: '/import', + builder: (context, state) => const ImportScreen(), + ), GoRoute( path: '/profile', builder: (context, state) => const ProfileScreen(), diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 79900ee6..30720bf6 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -39,6 +39,12 @@ class AppShell extends ConsumerWidget { icon: Icons.storefront_outlined, label: 'Baslager', ), + _AppDestination( + path: '/import', + title: 'Importera', + icon: Icons.upload_file_outlined, + label: 'Importera', + ), _AppDestination( path: '/profile', title: 'Profil', diff --git a/flutter/lib/features/import/data/import_providers.dart b/flutter/lib/features/import/data/import_providers.dart new file mode 100644 index 00000000..cc44e9f1 --- /dev/null +++ b/flutter/lib/features/import/data/import_providers.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'import_repository.dart'; + +final importRepositoryProvider = Provider( + (_) => ImportRepository(), +); diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart new file mode 100644 index 00000000..ef183ded --- /dev/null +++ b/flutter/lib/features/import/data/import_repository.dart @@ -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 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 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 _parseResponse(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + return jsonDecode(response.body) as Map; + } + + Map? body; + try { + body = jsonDecode(response.body) as Map; + } 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); + } +} diff --git a/flutter/lib/features/import/domain/quick_import_result.dart b/flutter/lib/features/import/domain/quick_import_result.dart new file mode 100644 index 00000000..29d835ec --- /dev/null +++ b/flutter/lib/features/import/domain/quick_import_result.dart @@ -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 json) => + QuickImportResult( + markdown: json['markdown'] as String? ?? '', + source: json['source'] as String? ?? 'other', + imageUrl: json['imageUrl'] as String?, + ); +} diff --git a/flutter/lib/features/import/presentation/import_screen.dart b/flutter/lib/features/import/presentation/import_screen.dart new file mode 100644 index 00000000..14c1f0f8 --- /dev/null +++ b/flutter/lib/features/import/presentation/import_screen.dart @@ -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 createState() => _ImportScreenState(); +} + +class _ImportScreenState extends State + 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, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/import/presentation/recipe_import_tab.dart b/flutter/lib/features/import/presentation/recipe_import_tab.dart new file mode 100644 index 00000000..28fffd48 --- /dev/null +++ b/flutter/lib/features/import/presentation/recipe_import_tab.dart @@ -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 createState() => _RecipeImportTabState(); +} + +class _RecipeImportTabState extends ConsumerState { + // 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 _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 _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 } diff --git a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart index dc2571c8..7e68433f 100644 --- a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart +++ b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart @@ -7,12 +7,46 @@ import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; import '../domain/inventory_item.dart'; +/// Returns a sensible step size for quick swipe-adjustments given [unit]. +/// +/// Examples: "g" → 50, "kg" → 0.1, "ml" → 50, "l" / "dl" → 0.1, +/// everything else (pieces, packages, etc.) → 1. +double _stepForUnit(String unit) { + switch (unit.trim().toLowerCase()) { + case 'g': + case 'gram': + return 50; + case 'kg': + case 'kilo': + case 'kilogram': + return 0.1; + case 'ml': + return 50; + case 'cl': + return 1; + case 'dl': + return 0.5; + case 'l': + case 'liter': + case 'litre': + return 0.1; + default: + return 1; + } +} + +/// Formats a step value for display: whole numbers without decimal, +/// fractions with one decimal. +String _fmtStep(double step) => + step == step.roundToDouble() ? step.toStringAsFixed(0) : step.toStringAsFixed(1); + /// A [ListTile] wrapped in a swipe-to-adjust widget. /// -/// • Swipe **right** (+) → adds 1 unit to [item.quantity] via PATCH. -/// • Swipe **left** (−) → consumes 1 unit via the consume endpoint, -/// preserving consumption history. +/// • Swipe **right** (+) → adds one unit-appropriate step via PATCH. +/// • Swipe **left** (−) → consumes one unit-appropriate step via the +/// consume endpoint, preserving consumption history. /// +/// The step size is determined by the item's unit (e.g. 50 g, 0.1 kg, 1 st). /// A small swipe-hint icon is shown at the start of the subtitle so users /// know the gesture is available without any extra instruction. class SwipeableInventoryTile extends ConsumerStatefulWidget { @@ -58,6 +92,7 @@ class _SwipeableInventoryTileState void _snapBack() => setState(() => _drag = 0); Future _adjust(int direction) async { + final step = _stepForUnit(widget.item.unit); setState(() => _acting = true); try { final token = await ref.read(authStateProvider.future); @@ -67,16 +102,17 @@ class _SwipeableInventoryTileState // Increase: direct PATCH with new quantity. await repo.updateInventoryItem( widget.item.id, - {'quantity': widget.item.quantity + 1}, + {'quantity': widget.item.quantity + step}, token: token, ); } else { // Decrease: use consume endpoint so history is preserved. // Guard against going below zero. if (widget.item.quantity <= 0) return; + final consume = step > widget.item.quantity ? widget.item.quantity : step; await repo.consumeInventoryItem( widget.item.id, - amountUsed: 1, + amountUsed: consume, token: token, ); } @@ -103,6 +139,10 @@ class _SwipeableInventoryTileState final rightProgress = (_drag / _threshold).clamp(0.0, 1.0); final leftProgress = (-_drag / _threshold).clamp(0.0, 1.0); + final step = _stepForUnit(widget.item.unit); + final stepLabel = _fmtStep(step); + final unit = widget.item.unit; + return GestureDetector( onHorizontalDragUpdate: _onUpdate, onHorizontalDragEnd: _onEnd, @@ -131,7 +171,7 @@ class _SwipeableInventoryTileState ), const SizedBox(height: 2), Text( - '+1', + '+$stepLabel $unit', style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold, @@ -167,7 +207,7 @@ class _SwipeableInventoryTileState ), const SizedBox(height: 2), Text( - '−1', + '−$stepLabel $unit', style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.bold, diff --git a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart index 6d1bb07f..f4f62558 100644 --- a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart +++ b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart @@ -11,7 +11,10 @@ import '../domain/parsed_recipe.dart'; enum _Step { input, review } class CreateRecipeScreen extends ConsumerStatefulWidget { - const CreateRecipeScreen({super.key}); + /// Optional markdown to pre-fill the input field, e.g. from import. + final String? initialMarkdown; + + const CreateRecipeScreen({super.key, this.initialMarkdown}); @override ConsumerState createState() => @@ -22,10 +25,16 @@ class _CreateRecipeScreenState extends ConsumerState { _Step _step = _Step.input; // Step 1 — markdown input - final _markdownCtrl = TextEditingController(); + late final TextEditingController _markdownCtrl; bool _isParsing = false; String? _parseError; + @override + void initState() { + super.initState(); + _markdownCtrl = TextEditingController(text: widget.initialMarkdown ?? ''); + } + // Step 2 — review state ParsedRecipe? _parsed; late TextEditingController _nameCtrl; @@ -39,7 +48,7 @@ class _CreateRecipeScreenState extends ConsumerState { @override void dispose() { - _markdownCtrl.dispose(); + _markdownCtrl.dispose(); // always non-null after initState if (_step == _Step.review) { _nameCtrl.dispose(); _servingsCtrl.dispose(); diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 2b41623e..2ff8347e 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: http: ^1.2.1 intl: any shared_preferences: ^2.2.3 + file_picker: ^8.0.0 dev_dependencies: flutter_test: diff --git a/next_steps_flutter.md b/next_steps_flutter.md index dd5cea23..1550f33b 100644 --- a/next_steps_flutter.md +++ b/next_steps_flutter.md @@ -72,8 +72,82 @@ Adminfloden migreras efter att ovanstaende ar verifierat. - [x] Swipe-för-±1 på inventarielistan (SwipeableInventoryTile med visuell ledtråd). ## Fas 6 - Import parity -- URL/PDF/bild via befintliga endpoints. -- Tydlig hantering av langkorande anrop och fel. + +### Analys (2026-04-22) + +**Två separata flöden — samma skärm med flikar:** + +#### 6a — Recept-import +- Endpoint: `POST /api/quick-import` +- Lägen: (1) filuppladdning med `multipart/form-data`, fält `file`, max 10 MB, + accepterade typer: PDF, PNG, JPG, JPEG, WEBP, BMP; + (2) URL via JSON-body `{ input: string }`. +- Svar: `{ markdown: string, source: 'ica'|'pdf'|'image'|'other', imageUrl?: string }`. +- På lyckat resultat: navigera till `/recipes/create` med markdown-texten förifylld. +- **Kräver**: `CreateRecipeScreen` måste utökas med en valfri `initialMarkdown`-parameter + som skickas via GoRouter `extra` (undviker persistent state-provider för tillfällig data). + +#### 6b — Kvitto-import +- Endpoint: `POST /api/receipt-import` +- Läge: filuppladdning, `multipart/form-data`, fält `file`, max 15 MB, + typer: JPEG, PNG, WebP, HEIC/HEIF, PDF. +- Svar: `ParsedReceiptItem[]` med fälten `rawName`, `quantity`, `unit`, + `price?`, `matchedProductId?`, `matchedProductName?`, + `suggestedProductId?`, `suggestedProductName?`, `categorySuggestion?`. +- På lyckat resultat: granskningssteg där användaren bekräftar/skippar rader + och väljer produkt (via `ProductPickerField`), sedan bulk-spara till inventarie. +- Komplexitetsgrad: hög — granskningsvyn är det tyngsta steget. + +**Nytt paket som krävs:** +- `file_picker: ^8.0.0` — hanterar filval på Flutter web (ger `Uint8List bytes`, + ingen filsökväg). Läggs till i `pubspec.yaml`. + +**Fil-/mappstruktur:** +``` +flutter/lib/features/import/ + domain/ + quick_import_result.dart # { markdown, source, imageUrl? } + parsed_receipt_item.dart # { rawName, quantity, unit, ... } + data/ + import_repository.dart # API-anrop (multipart + JSON URL-läge) + import_providers.dart # Riverpod-providers + presentation/ + import_screen.dart # TabBar: "Recept" | "Kvitto" + recipe_import_tab.dart # Fas 6a — fil + URL, laddningsindikator + receipt_import_tab.dart # Fas 6b — fil, parse, granskning, spara +``` + +**Router och shell:** +- Ny route `/import` inuti `ShellRoute` i `app_router.dart`. +- Ny nav-destination "Importera" med ikon `Icons.upload_file_outlined` i `app_shell.dart`, + placeras efter "Baslager" och innan "Profil". + +**Felhantering:** +- Multipart-uppladdning kan ta 5–30 s (OCR, LLM) — `LinearProgressIndicator` + med text "Tolkar…" under hela anropet, inte en vanlig spinner. +- Timeout via `http`-klienten: sätt `Duration(seconds: 120)` för import-anrop. +- Nätverks- och serverfel mappas via befintlig `mapErrorToUserMessage`. + +**Genomförandeordning:** +1. Lägg till `file_picker` i `pubspec.yaml`. +2. Utöka `CreateRecipeScreen` med `initialMarkdown`-parameter + GoRouter extra-stöd. +3. Bygg `domain/` + `data/` (modeller, repository, providers). +4. Bygg `recipe_import_tab.dart` (fas 6a — enklare). +5. Registrera route, lägg till nav-destination, verifiera end-to-end. +6. Bygg `receipt_import_tab.dart` (fas 6b — granskningssteg sist). + +### Deluppgifter +- [x] Lägg till `file_picker: ^8.0.0` i `pubspec.yaml`. +- [x] Utöka `CreateRecipeScreen` med optional `initialMarkdown` via GoRouter `extra`. +- [x] Skapa `domain/quick_import_result.dart` och `domain/parsed_receipt_item.dart`. +- [x] Skapa `data/import_repository.dart` med multipart-upload + JSON URL-metoder. +- [x] Skapa `data/import_providers.dart`. +- [x] Bygg `presentation/recipe_import_tab.dart` (fil + URL, lång laddningsindikator). +- [x] Bygg `presentation/import_screen.dart` med TabBar. +- [x] Registrera `/import` i router och lägg till nav-destination i AppShell. +- [ ] Verifiera recept-import end-to-end (fil + URL → create-screen). +- [ ] Bygg `presentation/receipt_import_tab.dart` (uppladdning + granskningssteg). +- [ ] Verifiera kvitto-import end-to-end (fil → parse → granska → inventarie). ## Fas 7 - Profil/admin parity - Profil for alla anvandare.