feat(import): implement recipe import functionality with file and URL support
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import '../../features/inventory/presentation/consume_inventory_screen.dart';
|
|||||||
import '../../features/inventory/presentation/consumption_history_screen.dart';
|
import '../../features/inventory/presentation/consumption_history_screen.dart';
|
||||||
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
|
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
|
||||||
import '../../features/pantry/presentation/pantry_screen.dart';
|
import '../../features/pantry/presentation/pantry_screen.dart';
|
||||||
|
import '../../features/import/presentation/import_screen.dart';
|
||||||
|
|
||||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authStateProvider);
|
final authState = ref.watch(authStateProvider);
|
||||||
@@ -66,7 +67,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
// /recipes/create must be listed before /recipes/:id to avoid conflict.
|
// /recipes/create must be listed before /recipes/:id to avoid conflict.
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/recipes/create',
|
path: '/recipes/create',
|
||||||
builder: (context, state) => const CreateRecipeScreen(),
|
builder: (context, state) {
|
||||||
|
final initialMarkdown = state.extra as String?;
|
||||||
|
return CreateRecipeScreen(initialMarkdown: initialMarkdown);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/recipes/:id',
|
path: '/recipes/:id',
|
||||||
@@ -168,6 +172,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/baslager',
|
path: '/baslager',
|
||||||
builder: (context, state) => const PantryScreen(),
|
builder: (context, state) => const PantryScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/import',
|
||||||
|
builder: (context, state) => const ImportScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfileScreen(),
|
builder: (context, state) => const ProfileScreen(),
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ class AppShell extends ConsumerWidget {
|
|||||||
icon: Icons.storefront_outlined,
|
icon: Icons.storefront_outlined,
|
||||||
label: 'Baslager',
|
label: 'Baslager',
|
||||||
),
|
),
|
||||||
|
_AppDestination(
|
||||||
|
path: '/import',
|
||||||
|
title: 'Importera',
|
||||||
|
icon: Icons.upload_file_outlined,
|
||||||
|
label: 'Importera',
|
||||||
|
),
|
||||||
_AppDestination(
|
_AppDestination(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
title: 'Profil',
|
title: 'Profil',
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -7,12 +7,46 @@ import '../../auth/data/auth_providers.dart';
|
|||||||
import '../data/inventory_providers.dart';
|
import '../data/inventory_providers.dart';
|
||||||
import '../domain/inventory_item.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.
|
/// A [ListTile] wrapped in a swipe-to-adjust widget.
|
||||||
///
|
///
|
||||||
/// • Swipe **right** (+) → adds 1 unit to [item.quantity] via PATCH.
|
/// • Swipe **right** (+) → adds one unit-appropriate step via PATCH.
|
||||||
/// • Swipe **left** (−) → consumes 1 unit via the consume endpoint,
|
/// • Swipe **left** (−) → consumes one unit-appropriate step via the
|
||||||
/// preserving consumption history.
|
/// 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
|
/// A small swipe-hint icon is shown at the start of the subtitle so users
|
||||||
/// know the gesture is available without any extra instruction.
|
/// know the gesture is available without any extra instruction.
|
||||||
class SwipeableInventoryTile extends ConsumerStatefulWidget {
|
class SwipeableInventoryTile extends ConsumerStatefulWidget {
|
||||||
@@ -58,6 +92,7 @@ class _SwipeableInventoryTileState
|
|||||||
void _snapBack() => setState(() => _drag = 0);
|
void _snapBack() => setState(() => _drag = 0);
|
||||||
|
|
||||||
Future<void> _adjust(int direction) async {
|
Future<void> _adjust(int direction) async {
|
||||||
|
final step = _stepForUnit(widget.item.unit);
|
||||||
setState(() => _acting = true);
|
setState(() => _acting = true);
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
@@ -67,16 +102,17 @@ class _SwipeableInventoryTileState
|
|||||||
// Increase: direct PATCH with new quantity.
|
// Increase: direct PATCH with new quantity.
|
||||||
await repo.updateInventoryItem(
|
await repo.updateInventoryItem(
|
||||||
widget.item.id,
|
widget.item.id,
|
||||||
{'quantity': widget.item.quantity + 1},
|
{'quantity': widget.item.quantity + step},
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Decrease: use consume endpoint so history is preserved.
|
// Decrease: use consume endpoint so history is preserved.
|
||||||
// Guard against going below zero.
|
// Guard against going below zero.
|
||||||
if (widget.item.quantity <= 0) return;
|
if (widget.item.quantity <= 0) return;
|
||||||
|
final consume = step > widget.item.quantity ? widget.item.quantity : step;
|
||||||
await repo.consumeInventoryItem(
|
await repo.consumeInventoryItem(
|
||||||
widget.item.id,
|
widget.item.id,
|
||||||
amountUsed: 1,
|
amountUsed: consume,
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,6 +139,10 @@ class _SwipeableInventoryTileState
|
|||||||
final rightProgress = (_drag / _threshold).clamp(0.0, 1.0);
|
final rightProgress = (_drag / _threshold).clamp(0.0, 1.0);
|
||||||
final leftProgress = (-_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(
|
return GestureDetector(
|
||||||
onHorizontalDragUpdate: _onUpdate,
|
onHorizontalDragUpdate: _onUpdate,
|
||||||
onHorizontalDragEnd: _onEnd,
|
onHorizontalDragEnd: _onEnd,
|
||||||
@@ -131,7 +171,7 @@ class _SwipeableInventoryTileState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'+1',
|
'+$stepLabel $unit',
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: colorScheme.onPrimaryContainer,
|
color: colorScheme.onPrimaryContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -167,7 +207,7 @@ class _SwipeableInventoryTileState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'−1',
|
'−$stepLabel $unit',
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import '../domain/parsed_recipe.dart';
|
|||||||
enum _Step { input, review }
|
enum _Step { input, review }
|
||||||
|
|
||||||
class CreateRecipeScreen extends ConsumerStatefulWidget {
|
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
|
@override
|
||||||
ConsumerState<CreateRecipeScreen> createState() =>
|
ConsumerState<CreateRecipeScreen> createState() =>
|
||||||
@@ -22,10 +25,16 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
|||||||
_Step _step = _Step.input;
|
_Step _step = _Step.input;
|
||||||
|
|
||||||
// Step 1 — markdown input
|
// Step 1 — markdown input
|
||||||
final _markdownCtrl = TextEditingController();
|
late final TextEditingController _markdownCtrl;
|
||||||
bool _isParsing = false;
|
bool _isParsing = false;
|
||||||
String? _parseError;
|
String? _parseError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_markdownCtrl = TextEditingController(text: widget.initialMarkdown ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2 — review state
|
// Step 2 — review state
|
||||||
ParsedRecipe? _parsed;
|
ParsedRecipe? _parsed;
|
||||||
late TextEditingController _nameCtrl;
|
late TextEditingController _nameCtrl;
|
||||||
@@ -39,7 +48,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_markdownCtrl.dispose();
|
_markdownCtrl.dispose(); // always non-null after initState
|
||||||
if (_step == _Step.review) {
|
if (_step == _Step.review) {
|
||||||
_nameCtrl.dispose();
|
_nameCtrl.dispose();
|
||||||
_servingsCtrl.dispose();
|
_servingsCtrl.dispose();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
http: ^1.2.1
|
http: ^1.2.1
|
||||||
intl: any
|
intl: any
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3
|
||||||
|
file_picker: ^8.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
+76
-2
@@ -72,8 +72,82 @@ Adminfloden migreras efter att ovanstaende ar verifierat.
|
|||||||
- [x] Swipe-för-±1 på inventarielistan (SwipeableInventoryTile med visuell ledtråd).
|
- [x] Swipe-för-±1 på inventarielistan (SwipeableInventoryTile med visuell ledtråd).
|
||||||
|
|
||||||
## Fas 6 - Import parity
|
## 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
|
## Fas 7 - Profil/admin parity
|
||||||
- Profil for alla anvandare.
|
- Profil for alla anvandare.
|
||||||
|
|||||||
Reference in New Issue
Block a user