feat(inventory): add inventory preview functionality and related models
This commit is contained in:
@@ -11,6 +11,7 @@ class RecipeApiPaths {
|
|||||||
static String detail(int id) => '/recipes/$id';
|
static String detail(int id) => '/recipes/$id';
|
||||||
static String update(int id) => '/recipes/$id';
|
static String update(int id) => '/recipes/$id';
|
||||||
static String remove(int id) => '/recipes/$id';
|
static String remove(int id) => '/recipes/$id';
|
||||||
|
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
|
||||||
static const parseMarkdown = '/recipes/parse-markdown';
|
static const parseMarkdown = '/recipes/parse-markdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../../core/api/api_providers.dart';
|
|||||||
import '../../../core/api/guarded_api_call.dart';
|
import '../../../core/api/guarded_api_call.dart';
|
||||||
import '../../../features/auth/data/auth_providers.dart';
|
import '../../../features/auth/data/auth_providers.dart';
|
||||||
import '../domain/recipe.dart';
|
import '../domain/recipe.dart';
|
||||||
|
import '../domain/inventory_preview.dart';
|
||||||
import 'recipe_repository.dart';
|
import 'recipe_repository.dart';
|
||||||
|
|
||||||
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
|
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
|
||||||
@@ -26,3 +27,14 @@ final recipeDetailProvider =
|
|||||||
() => ref.read(recipeRepositoryProvider).fetchRecipeDetail(id, token: token),
|
() => ref.read(recipeRepositoryProvider).fetchRecipeDetail(id, token: token),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final inventoryPreviewProvider =
|
||||||
|
FutureProvider.family<InventoryPreview, int>((ref, id) async {
|
||||||
|
final token = await ref.watch(authStateProvider.future);
|
||||||
|
return guardedApiCall(
|
||||||
|
ref,
|
||||||
|
() => ref
|
||||||
|
.read(recipeRepositoryProvider)
|
||||||
|
.fetchInventoryPreview(id, token: token),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import '../../../core/api/api_exception.dart';
|
|||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../domain/parsed_recipe.dart';
|
import '../domain/parsed_recipe.dart';
|
||||||
import '../domain/recipe.dart';
|
import '../domain/recipe.dart';
|
||||||
|
import '../domain/inventory_preview.dart';
|
||||||
|
|
||||||
class RecipeRepository {
|
class RecipeRepository {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
@@ -93,6 +94,27 @@ class RecipeRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<InventoryPreview> fetchInventoryPreview(int id,
|
||||||
|
{String? token}) async {
|
||||||
|
try {
|
||||||
|
final data = await _api.getJson(
|
||||||
|
RecipeApiPaths.inventoryPreview(id),
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) {
|
||||||
|
throw const ApiException(
|
||||||
|
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
|
||||||
|
}
|
||||||
|
return InventoryPreview.fromJson(data);
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
type: ApiErrorType.network,
|
||||||
|
message: 'Kunde inte hämta inventariestatus.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ParsedRecipe> parseMarkdown(String markdown,
|
Future<ParsedRecipe> parseMarkdown(String markdown,
|
||||||
{String? token}) async {
|
{String? token}) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
enum IngredientStatus { enough, missing, unitMismatch }
|
||||||
|
|
||||||
|
class IngredientPreview {
|
||||||
|
final int ingredientId;
|
||||||
|
final int productId;
|
||||||
|
final String productName;
|
||||||
|
final double requiredQuantity;
|
||||||
|
final String requiredUnit;
|
||||||
|
final String? note;
|
||||||
|
final double availableQuantity;
|
||||||
|
final IngredientStatus status;
|
||||||
|
final double missingQuantity;
|
||||||
|
|
||||||
|
const IngredientPreview({
|
||||||
|
required this.ingredientId,
|
||||||
|
required this.productId,
|
||||||
|
required this.productName,
|
||||||
|
required this.requiredQuantity,
|
||||||
|
required this.requiredUnit,
|
||||||
|
this.note,
|
||||||
|
required this.availableQuantity,
|
||||||
|
required this.status,
|
||||||
|
required this.missingQuantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory IngredientPreview.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawStatus = json['status'] as String? ?? 'missing';
|
||||||
|
final status = switch (rawStatus) {
|
||||||
|
'enough' => IngredientStatus.enough,
|
||||||
|
'unit_mismatch' => IngredientStatus.unitMismatch,
|
||||||
|
_ => IngredientStatus.missing,
|
||||||
|
};
|
||||||
|
return IngredientPreview(
|
||||||
|
ingredientId: json['ingredientId'] as int,
|
||||||
|
productId: json['productId'] as int,
|
||||||
|
productName: json['productName'] as String,
|
||||||
|
requiredQuantity: (json['requiredQuantity'] as num).toDouble(),
|
||||||
|
requiredUnit: json['requiredUnit'] as String? ?? '',
|
||||||
|
note: json['note'] as String?,
|
||||||
|
availableQuantity: (json['availableQuantity'] as num? ?? 0).toDouble(),
|
||||||
|
status: status,
|
||||||
|
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreviewSummary {
|
||||||
|
final int totalIngredients;
|
||||||
|
final int enoughCount;
|
||||||
|
final int missingCount;
|
||||||
|
final int unitMismatchCount;
|
||||||
|
final bool canCookExactly;
|
||||||
|
|
||||||
|
const PreviewSummary({
|
||||||
|
required this.totalIngredients,
|
||||||
|
required this.enoughCount,
|
||||||
|
required this.missingCount,
|
||||||
|
required this.unitMismatchCount,
|
||||||
|
required this.canCookExactly,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PreviewSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PreviewSummary(
|
||||||
|
totalIngredients: json['totalIngredients'] as int,
|
||||||
|
enoughCount: json['enoughCount'] as int,
|
||||||
|
missingCount: json['missingCount'] as int,
|
||||||
|
unitMismatchCount: json['unitMismatchCount'] as int,
|
||||||
|
canCookExactly: json['canCookExactly'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InventoryPreview {
|
||||||
|
final List<IngredientPreview> ingredients;
|
||||||
|
final PreviewSummary summary;
|
||||||
|
|
||||||
|
const InventoryPreview({
|
||||||
|
required this.ingredients,
|
||||||
|
required this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory InventoryPreview.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawIngredients = json['ingredients'] as List<dynamic>? ?? [];
|
||||||
|
return InventoryPreview(
|
||||||
|
ingredients: rawIngredients
|
||||||
|
.map((e) => IngredientPreview.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
summary: PreviewSummary.fromJson(
|
||||||
|
json['summary'] as Map<String, dynamic>? ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import '../../../core/ui/async_state_views.dart';
|
|||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../data/recipe_providers.dart';
|
import '../data/recipe_providers.dart';
|
||||||
import '../domain/recipe.dart';
|
import '../domain/recipe.dart';
|
||||||
|
import '../domain/inventory_preview.dart';
|
||||||
|
|
||||||
class RecipeDetailScreen extends ConsumerWidget {
|
class RecipeDetailScreen extends ConsumerWidget {
|
||||||
final int recipeId;
|
final int recipeId;
|
||||||
@@ -182,9 +183,208 @@ class _RecipeBody extends StatelessWidget {
|
|||||||
style: theme.textTheme.bodyMedium
|
style: theme.textTheme.bodyMedium
|
||||||
?.copyWith(height: 1.6)),
|
?.copyWith(height: 1.6)),
|
||||||
],
|
],
|
||||||
|
_InventoryPreviewSection(recipeId: recipe.id),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
@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: 'Uppdatera',
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () {
|
||||||
|
ref.invalidate(inventoryPreviewProvider(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(inventoryPreviewProvider(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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryChips extends StatelessWidget {
|
||||||
|
final PreviewSummary 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.canCookExactly)
|
||||||
|
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.unitMismatchCount > 0)
|
||||||
|
Chip(
|
||||||
|
avatar: Icon(Icons.swap_horiz,
|
||||||
|
color: cs.onTertiaryContainer, size: 16),
|
||||||
|
label: Text('${summary.unitMismatchCount} enhetsmismatch'),
|
||||||
|
backgroundColor: cs.tertiaryContainer,
|
||||||
|
labelStyle: TextStyle(color: cs.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IngredientPreviewRow extends StatelessWidget {
|
||||||
|
final IngredientPreview ingredient;
|
||||||
|
|
||||||
|
const _IngredientPreviewRow({required this.ingredient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final cs = theme.colorScheme;
|
||||||
|
|
||||||
|
final (icon, color) = switch (ingredient.status) {
|
||||||
|
IngredientStatus.enough => (Icons.check_circle_outline, cs.primary),
|
||||||
|
IngredientStatus.unitMismatch => (
|
||||||
|
Icons.swap_horiz,
|
||||||
|
cs.tertiary,
|
||||||
|
),
|
||||||
|
IngredientStatus.missing => (Icons.cancel_outlined, cs.error),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _fmt(double v) =>
|
||||||
|
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
|
||||||
|
|
||||||
|
final requiredStr =
|
||||||
|
'${_fmt(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
|
||||||
|
final availableStr =
|
||||||
|
'${_fmt(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
|
||||||
|
|
||||||
|
final subtitle = switch (ingredient.status) {
|
||||||
|
IngredientStatus.enough => 'Tillgängligt: $availableStr',
|
||||||
|
IngredientStatus.missing => ingredient.availableQuantity > 0
|
||||||
|
? 'Saknar ${_fmt(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
|
||||||
|
'(har $availableStr)'
|
||||||
|
: 'Saknas helt',
|
||||||
|
IngredientStatus.unitMismatch =>
|
||||||
|
'Annan enhet i lager – kontrollera manuellt',
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
'${ingredient.productName}'
|
||||||
|
'${ingredient.note != null ? ' (${ingredient.note})' : ''}'
|
||||||
|
' – $requiredStr',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user