Files
Nils-Johan Gynther 04b1fc3024
Test Suite / test (24.15.0) (push) Has been cancelled
feat: add rematch functionality for recipe ingredients and enhance inventory management
- Added a new API path for rematching recipe ingredients in `api_paths.dart`.
- Implemented a manual product creation dialog in `inventory_screen.dart` to allow users to create new products directly.
- Integrated the rematch functionality in `recipe_repository.dart` to handle rematching of recipe ingredients.
- Updated the recipe detail screen to include a button for triggering the rematch process.
- Introduced a new `RecipeMatchingService` in the backend to handle ingredient matching logic.
- Added database migration to include `aiEngineEnabled` column in the User table.

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 09:20:31 +02:00

774 lines
27 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/utils/formatters.dart';
import '../../../core/auth/jwt_decoder.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
import '../domain/recipe.dart';
import '../domain/recipe_analysis.dart';
String _fmtQty(double v) => formatQuantity(v);
enum _ShareAction { share, unshare }
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));
final token = ref.watch(authStateProvider).maybeWhen(
data: (t) => t,
orElse: () => null,
);
final currentUserId = jwtUserId(token);
final recipe = recipeAsync.asData?.value;
final isOwner = recipe != null && currentUserId != null && recipe.ownerId == currentUserId;
return Scaffold(
appBar: AppBar(
title: const SizedBox.shrink(),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/recipes'),
tooltip: context.l10n.recipeDetailBackToList,
),
actions: recipe == null
? []
: [
if (isOwner)
IconButton(
tooltip: recipe.isPublic ? context.l10n.recipeDetailMakePrivate : context.l10n.recipeDetailMakePublic,
icon: Icon(recipe.isPublic ? Icons.public : Icons.lock_outline),
onPressed: () => _toggleVisibility(context, ref, recipe),
),
if (isOwner)
IconButton(
tooltip: context.l10n.recipeDetailShareWithUser,
icon: const Icon(Icons.person_add_alt_1_outlined),
onPressed: () => _shareRecipe(context, ref, recipe),
),
IconButton(
tooltip: context.l10n.editTooltip,
icon: const Icon(Icons.edit_outlined),
onPressed: () =>
context.push('/recipes/$recipeId/edit'),
),
IconButton(
tooltip: context.l10n.recipeDetailGoToInventory,
icon: const Icon(Icons.inventory_2_outlined),
onPressed: () => context.go('/inventory'),
),
if (isOwner) _DeleteButton(recipe: recipe),
],
),
body: recipeAsync.when(
loading: () => LoadingStateView(label: context.l10n.recipeDetailLoading),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)),
),
data: (recipe) => CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverAppBar(
expandedHeight: MediaQuery.of(context).size.height * 0.42,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
recipe.imageUrl != null
? Image.network(
recipe.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _ImagePlaceholder(),
)
: _ImagePlaceholder(),
// Gradient + title overlay
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
child: Text(
recipe.title,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
shadows: [Shadow(blurRadius: 4, color: Colors.black54)],
),
),
),
),
if (recipe.isPublic &&
recipe.ownerUsername != null &&
recipe.ownerUsername!.isNotEmpty)
Positioned(
right: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'@${recipe.ownerUsername}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
pinned: true,
),
SliverToBoxAdapter(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: _RecipeBody(recipe: recipe),
),
),
],
),
),
);
}
Future<void> _toggleVisibility(
BuildContext context,
WidgetRef ref,
Recipe recipe,
) async {
try {
final token = ref.read(authStateProvider).maybeWhen(
data: (t) => t,
orElse: () => null,
) ??
await ref.read(authStateProvider.future);
await ref.read(recipeRepositoryProvider).setRecipeVisibility(
recipe.id,
isPublic: !recipe.isPublic,
token: token,
);
ref.invalidate(recipeDetailProvider(recipe.id));
ref.invalidate(recipesProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!recipe.isPublic
? context.l10n.recipeDetailNowPublic
: context.l10n.recipeDetailNowPrivate,
),
),
);
} on ApiException catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _shareRecipe(
BuildContext context,
WidgetRef ref,
Recipe recipe,
) async {
final ctrl = TextEditingController();
final result = await showDialog<(_ShareAction, String)>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.recipeDetailShareTitle),
content: TextField(
controller: ctrl,
autofocus: true,
decoration: InputDecoration(
labelText: context.l10n.recipeDetailUsernameLabel,
hintText: context.l10n.recipeDetailUsernameHint,
),
onSubmitted: (_) => Navigator.pop(
context,
(_ShareAction.share, ctrl.text.trim()),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancelAction),
),
TextButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.unshare, ctrl.text.trim()),
),
child: Text(context.l10n.recipeDetailRemoveShare),
),
FilledButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.share, ctrl.text.trim()),
),
child: Text(context.l10n.recipeDetailShareAction),
),
],
),
);
ctrl.dispose();
final action = result?.$1;
final trimmed = result?.$2.trim() ?? '';
if (trimmed.isEmpty) return;
try {
final token = ref.read(authStateProvider).maybeWhen(
data: (t) => t,
orElse: () => null,
) ??
await ref.read(authStateProvider.future);
if (action == _ShareAction.unshare) {
await ref.read(recipeRepositoryProvider).unshareRecipeWithUsername(
recipe.id,
username: trimmed,
token: token,
);
} else {
await ref.read(recipeRepositoryProvider).shareRecipeWithUsername(
recipe.id,
username: trimmed,
token: token,
);
}
ref.invalidate(recipeDetailProvider(recipe.id));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
action == _ShareAction.unshare
? context.l10n.recipeDetailSharingRemoved(trimmed)
: context.l10n.recipeDetailSharedWith(trimmed),
),
),
);
} on ApiException catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
}
class _ImagePlaceholder extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.restaurant,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
);
}
}
class _DeleteButton extends ConsumerWidget {
final Recipe recipe;
const _DeleteButton({required this.recipe});
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: context.l10n.deleteTooltip,
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: Text(context.l10n.recipeDetailDeleteTitle),
content: Text(context.l10n.recipeDetailDeleteContent(recipe.title)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.cancelAction),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error),
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.deleteAction),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
final token = ref.read(authStateProvider).maybeWhen(
data: (t) => t,
orElse: () => null,
) ??
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(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
}
class _RecipeBody extends StatelessWidget {
final Recipe recipe;
const _RecipeBody({required this.recipe});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titel visas som overlay på bilden — inte upprepas här
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} ${context.l10n.recipeDetailServings}',
style: theme.textTheme.bodySmall),
],
),
],
if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 24),
Text(context.l10n.recipeDetailIngredients, style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
...recipe.ingredients.map((ing) {
final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity);
final ingredientLabel = (ing.rawName.trim().isNotEmpty
? ing.rawName
: (ing.productName ?? '').trim())
.trim();
final measureParts = [
if (qtyStr.isNotEmpty) qtyStr,
if (ing.unit.isNotEmpty) ing.unit,
];
final measure = measureParts.join(' ');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (measure.isNotEmpty) ...[
Container(
width: 72,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
measure,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 10),
] else
const SizedBox(width: 82),
Expanded(
child: Text(
ing.note != null
? '$ingredientLabel (${ing.note})'
: ingredientLabel,
style: theme.textTheme.bodyMedium,
),
),
],
),
);
}),
],
if (recipe.instructions != null &&
recipe.instructions!.isNotEmpty) ...[
const SizedBox(height: 32),
Text(context.l10n.recipeDetailInstructions, style: theme.textTheme.titleMedium),
const SizedBox(height: 16),
..._buildSteps(recipe.instructions!, theme),
],
_InventoryPreviewSection(recipeId: recipe.id),
const SizedBox(height: 40),
],
),
);
}
List<Widget> _buildSteps(String instructions, ThemeData theme) {
final steps = instructions
.split(RegExp(r'\n{2,}'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
return steps.asMap().entries.map((entry) {
final index = entry.key + 1;
final text = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Text(
'$index',
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(height: 1.6),
),
),
),
],
),
);
}).toList();
}
}
class _InventoryPreviewSection extends ConsumerStatefulWidget {
final int recipeId;
const _InventoryPreviewSection({required this.recipeId});
@override
ConsumerState<_InventoryPreviewSection> createState() =>
_InventoryPreviewSectionState();
}
class _InventoryPreviewSectionState
extends ConsumerState<_InventoryPreviewSection> {
bool _loaded = false;
Future<void> _runRematch() async {
try {
final token = ref.read(authStateProvider).maybeWhen(
data: (t) => t,
orElse: () => null,
) ??
await ref.read(authStateProvider.future);
await ref
.read(recipeRepositoryProvider)
.rematchRecipeIngredients(widget.recipeId, token: token);
ref.invalidate(recipeAnalysisProvider(widget.recipeId));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Matchning uppdaterad')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Row(
children: [
Text('Vad saknas?', style: theme.textTheme.titleMedium),
const Spacer(),
if (!_loaded)
FilledButton.tonalIcon(
onPressed: () => setState(() => _loaded = true),
icon: const Icon(Icons.search, size: 16),
label: const Text('Kontrollera inventarie'),
),
if (_loaded)
IconButton(
tooltip: 'Kör om matchning',
icon: const Icon(Icons.auto_fix_high),
onPressed: _runRematch,
),
if (_loaded)
IconButton(
tooltip: 'Uppdatera',
icon: const Icon(Icons.refresh),
onPressed: () {
ref.invalidate(recipeAnalysisProvider(widget.recipeId));
},
),
],
),
if (_loaded) _InventoryPreviewResults(recipeId: widget.recipeId),
],
);
}
}
class _InventoryPreviewResults extends ConsumerWidget {
final int recipeId;
const _InventoryPreviewResults({required this.recipeId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final previewAsync = ref.watch(recipeAnalysisProvider(recipeId));
final theme = Theme.of(context);
return previewAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
),
error: (error, _) => Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
mapErrorToUserMessage(error, context),
style: TextStyle(color: theme.colorScheme.error),
),
),
data: (preview) {
final summary = preview.summary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
_SummaryChips(summary: summary),
const SizedBox(height: 12),
...preview.ingredients.map(
(ing) => _IngredientPreviewRow(ingredient: ing),
),
if (preview.shoppingListCandidates.isNotEmpty) ...[
const SizedBox(height: 16),
Text('Shoppinglista', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...preview.shoppingListCandidates.map((item) {
final qty = _fmtQty(item.missingQuantity > 0 ? item.missingQuantity : item.quantity);
final measure = '$qty ${item.unit}'.trim();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.shopping_cart_outlined, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
measure.isEmpty
? item.rawName
: '$measure ${item.rawName}',
style: theme.textTheme.bodySmall,
),
),
],
),
);
}),
],
],
);
},
);
}
}
class _SummaryChips extends StatelessWidget {
final RecipeAnalysisSummary summary;
const _SummaryChips({required this.summary});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (summary.missingCount == 0)
Chip(
avatar: Icon(Icons.check_circle,
color: cs.onPrimary, size: 16),
label: const Text('Kan lagas!'),
backgroundColor: cs.primary,
labelStyle: TextStyle(color: cs.onPrimary),
)
else
Chip(
avatar: Icon(Icons.warning_amber,
color: cs.onErrorContainer, size: 16),
label: Text('Saknar ${summary.missingCount} ingrediens'
'${summary.missingCount == 1 ? '' : 'er'}'),
backgroundColor: cs.errorContainer,
labelStyle: TextStyle(color: cs.onErrorContainer),
),
if (summary.substituteCount > 0)
Chip(
avatar: Icon(Icons.swap_horiz,
color: cs.onTertiaryContainer, size: 16),
label: Text('${summary.substituteCount} ersättningsbar'),
backgroundColor: cs.tertiaryContainer,
labelStyle: TextStyle(color: cs.onTertiaryContainer),
),
if (summary.pantryCount > 0)
Chip(
avatar: Icon(Icons.kitchen_outlined,
color: cs.onSecondaryContainer, size: 16),
label: Text('${summary.pantryCount} i skafferiet'),
backgroundColor: cs.secondaryContainer,
labelStyle: TextStyle(color: cs.onSecondaryContainer),
),
],
);
}
}
class _IngredientPreviewRow extends StatelessWidget {
final RecipeIngredientAnalysis ingredient;
const _IngredientPreviewRow({required this.ingredient});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final matchedName = ingredient.matchedProductName?.trim() ?? '';
final label = matchedName.isEmpty
? (ingredient.rawName.trim().isEmpty ? 'Okänd ingrediens' : ingredient.rawName)
: matchedName;
final (icon, color) = switch (ingredient.status) {
RecipeIngredientAvailabilityStatus.coveredByPantry => (Icons.kitchen_outlined, cs.secondary),
RecipeIngredientAvailabilityStatus.exactMatch => (Icons.check_circle_outline, cs.primary),
RecipeIngredientAvailabilityStatus.substitutable => (Icons.swap_horiz, cs.tertiary),
RecipeIngredientAvailabilityStatus.missing => (Icons.cancel_outlined, cs.error),
};
final effectiveUnit = ingredient.unit;
final requiredStr =
'${_fmtQty(ingredient.quantity)} $effectiveUnit'.trim();
final availableStr =
'${_fmtQty(ingredient.availableQuantity)} $effectiveUnit'.trim();
final subtitle = switch (ingredient.status) {
RecipeIngredientAvailabilityStatus.coveredByPantry => 'Finns i skafferiet',
RecipeIngredientAvailabilityStatus.exactMatch => 'Tillgängligt: $availableStr',
RecipeIngredientAvailabilityStatus.substitutable =>
ingredient.matchedProductName == null || ingredient.matchedProductName!.trim().isEmpty
? 'Kan ersättas med annan vara'
: 'Kan ersättas med ${ingredient.matchedProductName}',
RecipeIngredientAvailabilityStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} $effectiveUnit (har $availableStr)'
: 'Saknas helt',
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$label'
'${ingredient.note != null ? ' (${ingredient.note})' : ''}'
' $requiredStr',
style: theme.textTheme.bodyMedium,
),
Text(
subtitle,
style: theme.textTheme.bodySmall
?.copyWith(color: color),
),
],
),
),
],
),
);
}
}