feat: add recipe creation, editing, and detail screens; enhance recipe model with instructions and ingredients
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
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 '../../../core/api/api_exception.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/recipe_providers.dart';
|
||||
import '../domain/parsed_recipe.dart';
|
||||
|
||||
enum _Step { input, review }
|
||||
|
||||
class CreateRecipeScreen extends ConsumerStatefulWidget {
|
||||
const CreateRecipeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CreateRecipeScreen> createState() =>
|
||||
_CreateRecipeScreenState();
|
||||
}
|
||||
|
||||
class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
_Step _step = _Step.input;
|
||||
|
||||
// Step 1 — markdown input
|
||||
final _markdownCtrl = TextEditingController();
|
||||
bool _isParsing = false;
|
||||
String? _parseError;
|
||||
|
||||
// Step 2 — review state
|
||||
ParsedRecipe? _parsed;
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _servingsCtrl;
|
||||
late List<bool> _included;
|
||||
late Map<int, int?> _selectedProductIds;
|
||||
late Map<int, String?> _selectedProductNames;
|
||||
|
||||
bool _isSaving = false;
|
||||
String? _saveError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_markdownCtrl.dispose();
|
||||
if (_step == _Step.review) {
|
||||
_nameCtrl.dispose();
|
||||
_servingsCtrl.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initReviewState(ParsedRecipe parsed) {
|
||||
_nameCtrl = TextEditingController(text: parsed.name);
|
||||
_servingsCtrl = TextEditingController();
|
||||
_included = List.generate(parsed.ingredients.length, (_) => true);
|
||||
_selectedProductIds = {};
|
||||
_selectedProductNames = {};
|
||||
for (var i = 0; i < parsed.ingredients.length; i++) {
|
||||
final suggestions = parsed.ingredients[i].suggestions;
|
||||
if (suggestions.isNotEmpty) {
|
||||
_selectedProductIds[i] = suggestions.first.productId;
|
||||
_selectedProductNames[i] = suggestions.first.productName;
|
||||
} else {
|
||||
_selectedProductIds[i] = null;
|
||||
_selectedProductNames[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _parseMarkdown() async {
|
||||
final markdown = _markdownCtrl.text.trim();
|
||||
if (markdown.isEmpty) {
|
||||
setState(() => _parseError = 'Klistra in eller skriv ett recept i Markdown-format.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isParsing = true;
|
||||
_parseError = null;
|
||||
});
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final parsed = await ref
|
||||
.read(recipeRepositoryProvider)
|
||||
.parseMarkdown(markdown, token: token);
|
||||
_initReviewState(parsed);
|
||||
setState(() {
|
||||
_parsed = parsed;
|
||||
_step = _Step.review;
|
||||
_isParsing = false;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
if (e.type == ApiErrorType.unauthorized) {
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_parseError = mapErrorToUserMessage(e);
|
||||
_isParsing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _saveError = 'Receptnamnet får inte vara tomt.');
|
||||
return;
|
||||
}
|
||||
|
||||
final ingredients = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < _parsed!.ingredients.length; i++) {
|
||||
if (!_included[i]) continue;
|
||||
final productId = _selectedProductIds[i];
|
||||
if (productId == null) continue;
|
||||
final ing = _parsed!.ingredients[i];
|
||||
ingredients.add({
|
||||
'productId': productId,
|
||||
'quantity': ing.quantity,
|
||||
'unit': ing.unit,
|
||||
if (ing.note != null) 'note': ing.note,
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
_saveError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final servingsRaw = int.tryParse(_servingsCtrl.text.trim());
|
||||
final created = await ref.read(recipeRepositoryProvider).createRecipe(
|
||||
{
|
||||
'name': name,
|
||||
if (_parsed!.description != null) 'description': _parsed!.description,
|
||||
if (servingsRaw != null) 'servings': servingsRaw,
|
||||
if (_parsed!.instructions != null)
|
||||
'instructions': _parsed!.instructions,
|
||||
'ingredients': ingredients,
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
ref.invalidate(recipesProvider);
|
||||
if (mounted) context.go('/recipes/${created.id}');
|
||||
} on ApiException catch (e) {
|
||||
if (e.type == ApiErrorType.unauthorized) {
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saveError = mapErrorToUserMessage(e);
|
||||
_isSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
Text(_step == _Step.input ? 'Nytt recept' : 'Granska ingredienser'),
|
||||
leading: _step == _Step.review
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => setState(() {
|
||||
_step = _Step.input;
|
||||
_nameCtrl.dispose();
|
||||
_servingsCtrl.dispose();
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: _step == _Step.input ? _buildInputStep() : _buildReviewStep(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputStep() {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: TextField(
|
||||
controller: _markdownCtrl,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '# Receptnamn\n\n## Ingredienser\n- 500 g köttfärs\n- 1 st lök\n\n## Tillvägagångssätt\nStek löken...',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_parseError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_parseError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isParsing ? null : _parseMarkdown,
|
||||
child: _isParsing
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Granska ingredienser'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReviewStep() {
|
||||
final parsed = _parsed!;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Receptnamn'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _servingsCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Antal portioner (valfritt)'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
if (parsed.ingredients.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text('Ingredienser',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Bocka av ingredienser att inkludera och välj rätt produkt.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...List.generate(
|
||||
parsed.ingredients.length,
|
||||
(i) => _buildIngredientRow(i, parsed.ingredients[i])),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_saveError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_saveError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Spara recept'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIngredientRow(int index, ParsedIngredient ing) {
|
||||
final qtyStr = ing.quantity > 0
|
||||
? (ing.quantity == ing.quantity.truncateToDouble()
|
||||
? '${ing.quantity.toInt()} '
|
||||
: '${ing.quantity} ')
|
||||
: '';
|
||||
final unitStr = ing.unit.isNotEmpty ? '${ing.unit} ' : '';
|
||||
final noteStr = ing.note != null ? ' (${ing.note})' : '';
|
||||
final label = '$qtyStr$unitStr${ing.rawName}$noteStr';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: CheckboxListTile(
|
||||
value: _included[index],
|
||||
onChanged: (v) => setState(() => _included[index] = v ?? false),
|
||||
title: Text(label),
|
||||
subtitle: ing.suggestions.isEmpty
|
||||
? Text(
|
||||
'Ingen produkt hittades — ingrediensen hoppas över.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 12),
|
||||
)
|
||||
: DropdownButton<int>(
|
||||
value: _selectedProductIds[index],
|
||||
isExpanded: true,
|
||||
onChanged: _included[index]
|
||||
? (id) {
|
||||
if (id == null) return;
|
||||
setState(() {
|
||||
_selectedProductIds[index] = id;
|
||||
_selectedProductNames[index] = ing.suggestions
|
||||
.firstWhere((s) => s.productId == id)
|
||||
.productName;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
items: ing.suggestions
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s.productId,
|
||||
child: Text(s.productName),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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 '../../../core/api/api_exception.dart';
|
||||
import '../../../core/ui/async_state_views.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/recipe_providers.dart';
|
||||
import '../domain/recipe.dart';
|
||||
|
||||
class RecipeDetailScreen extends ConsumerWidget {
|
||||
final int recipeId;
|
||||
|
||||
const RecipeDetailScreen({super.key, required this.recipeId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recipeAsync = ref.watch(recipeDetailProvider(recipeId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(recipeAsync.valueOrNull?.title ?? 'Recept'),
|
||||
actions: recipeAsync.valueOrNull == null
|
||||
? []
|
||||
: [
|
||||
IconButton(
|
||||
tooltip: 'Redigera',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () =>
|
||||
context.push('/recipes/$recipeId/edit'),
|
||||
),
|
||||
_DeleteButton(recipe: recipeAsync.value!),
|
||||
],
|
||||
),
|
||||
body: recipeAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar recept...'),
|
||||
error: (error, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(error),
|
||||
onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)),
|
||||
),
|
||||
data: (recipe) => _RecipeBody(recipe: recipe),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteButton extends ConsumerWidget {
|
||||
final Recipe recipe;
|
||||
|
||||
const _DeleteButton({required this.recipe});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return IconButton(
|
||||
tooltip: 'Ta bort',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Ta bort recept?'),
|
||||
content: Text(
|
||||
'Vill du ta bort "${recipe.title}"? Åtgärden kan inte ångras.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Avbryt'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Ta bort'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
await ref.read(recipeRepositoryProvider).deleteRecipe(recipe.id,
|
||||
token: token);
|
||||
ref.invalidate(recipesProvider);
|
||||
if (context.mounted) context.go('/recipes');
|
||||
} on ApiException catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RecipeBody extends StatelessWidget {
|
||||
final Recipe recipe;
|
||||
|
||||
const _RecipeBody({required this.recipe});
|
||||
|
||||
String _formatQty(double qty) {
|
||||
if (qty == 0) return '';
|
||||
return qty == qty.truncateToDouble()
|
||||
? qty.toInt().toString()
|
||||
: qty.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (recipe.imageUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Image.network(
|
||||
recipe.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (recipe.imageUrl != null) const SizedBox(height: 20),
|
||||
Text(recipe.title, style: theme.textTheme.headlineSmall),
|
||||
if (recipe.description != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(recipe.description!,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
if (recipe.servings != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.people_outline, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text('${recipe.servings} portioner',
|
||||
style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (recipe.ingredients.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text('Ingredienser', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...recipe.ingredients.map((ing) {
|
||||
final qtyStr = _formatQty(ing.quantity);
|
||||
final parts = [
|
||||
if (qtyStr.isNotEmpty) qtyStr,
|
||||
if (ing.unit.isNotEmpty) ing.unit,
|
||||
ing.productName,
|
||||
if (ing.note != null) '(${ing.note})',
|
||||
];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• '),
|
||||
Expanded(child: Text(parts.join(' '))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
if (recipe.instructions != null &&
|
||||
recipe.instructions!.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text('Tillvägagångssätt', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(recipe.instructions!,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(height: 1.6)),
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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 '../../../core/api/api_exception.dart';
|
||||
import '../../../core/ui/async_state_views.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/recipe_providers.dart';
|
||||
import '../domain/recipe.dart';
|
||||
|
||||
class RecipeEditScreen extends ConsumerStatefulWidget {
|
||||
final int recipeId;
|
||||
|
||||
const RecipeEditScreen({super.key, required this.recipeId});
|
||||
|
||||
@override
|
||||
ConsumerState<RecipeEditScreen> createState() => _RecipeEditScreenState();
|
||||
}
|
||||
|
||||
class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _initialized = false;
|
||||
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _descCtrl;
|
||||
late TextEditingController _servingsCtrl;
|
||||
late TextEditingController _instructionsCtrl;
|
||||
|
||||
bool _isSaving = false;
|
||||
String? _saveError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_initialized) {
|
||||
_nameCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_servingsCtrl.dispose();
|
||||
_instructionsCtrl.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initControllers(Recipe recipe) {
|
||||
_nameCtrl = TextEditingController(text: recipe.title);
|
||||
_descCtrl = TextEditingController(text: recipe.description ?? '');
|
||||
_servingsCtrl =
|
||||
TextEditingController(text: recipe.servings?.toString() ?? '');
|
||||
_instructionsCtrl =
|
||||
TextEditingController(text: recipe.instructions ?? '');
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
_saveError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final servings = int.tryParse(_servingsCtrl.text.trim());
|
||||
await ref.read(recipeRepositoryProvider).updateRecipe(
|
||||
widget.recipeId,
|
||||
{
|
||||
'name': _nameCtrl.text.trim(),
|
||||
'description': _descCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _descCtrl.text.trim(),
|
||||
if (servings != null) 'servings': servings,
|
||||
'instructions': _instructionsCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _instructionsCtrl.text.trim(),
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
ref.invalidate(recipeDetailProvider(widget.recipeId));
|
||||
ref.invalidate(recipesProvider);
|
||||
if (mounted) context.pop();
|
||||
} on ApiException catch (e) {
|
||||
if (e.type == ApiErrorType.unauthorized) {
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saveError = mapErrorToUserMessage(e);
|
||||
_isSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final recipeAsync = ref.watch(recipeDetailProvider(widget.recipeId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Redigera recept'),
|
||||
actions: [
|
||||
if (_initialized)
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Spara'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: recipeAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar recept...'),
|
||||
error: (error, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(error),
|
||||
onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)),
|
||||
),
|
||||
data: (recipe) {
|
||||
if (!_initialized) _initControllers(recipe);
|
||||
return _buildForm(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Receptnamn'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Ange ett receptnamn.' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Beskrivning (valfritt)'),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _servingsCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Antal portioner (valfritt)'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return null;
|
||||
if (int.tryParse(v.trim()) == null) {
|
||||
return 'Ange ett heltal.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _instructionsCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Tillvägagångssätt (valfritt)'),
|
||||
maxLines: 10,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ingredienser redigeras via Skapa recept-flödet.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (_saveError != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_saveError!,
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Spara ändringar'),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 '../../../core/ui/async_state_views.dart';
|
||||
@@ -11,40 +12,49 @@ class RecipesScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recipesAsync = ref.watch(recipesProvider);
|
||||
return recipesAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar recept...'),
|
||||
error: (error, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(error),
|
||||
onRetry: () => ref.invalidate(recipesProvider),
|
||||
),
|
||||
data: (recipes) {
|
||||
if (recipes.isEmpty) {
|
||||
return const EmptyStateView(
|
||||
title: 'Inga recept hittades',
|
||||
description: 'Lagg till ett recept for att komma igang.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: recipes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recipe = recipes[index];
|
||||
return ListTile(
|
||||
leading: recipe.imageUrl != null
|
||||
? Image.network(recipe.imageUrl!, width: 56, fit: BoxFit.cover)
|
||||
: const Icon(Icons.restaurant),
|
||||
title: Text(recipe.title),
|
||||
subtitle: recipe.description != null
|
||||
? Text(
|
||||
recipe.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
return Scaffold(
|
||||
body: recipesAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar recept...'),
|
||||
error: (error, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(error),
|
||||
onRetry: () => ref.invalidate(recipesProvider),
|
||||
),
|
||||
data: (recipes) {
|
||||
if (recipes.isEmpty) {
|
||||
return const EmptyStateView(
|
||||
title: 'Inga recept hittades',
|
||||
description: 'Lägg till ett recept för att komma igång.',
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: recipes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recipe = recipes[index];
|
||||
return ListTile(
|
||||
leading: recipe.imageUrl != null
|
||||
? Image.network(recipe.imageUrl!, width: 56, fit: BoxFit.cover)
|
||||
: const Icon(Icons.restaurant),
|
||||
title: Text(recipe.title),
|
||||
subtitle: recipe.description != null
|
||||
? Text(
|
||||
recipe.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/recipes/${recipe.id}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
tooltip: 'Nytt recept',
|
||||
onPressed: () => context.push('/recipes/create'),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user