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 update(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';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../core/api/api_providers.dart';
|
||||
import '../../../core/api/guarded_api_call.dart';
|
||||
import '../../../features/auth/data/auth_providers.dart';
|
||||
import '../domain/recipe.dart';
|
||||
import '../domain/inventory_preview.dart';
|
||||
import 'recipe_repository.dart';
|
||||
|
||||
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
|
||||
@@ -26,3 +27,14 @@ final recipeDetailProvider =
|
||||
() => 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 '../domain/parsed_recipe.dart';
|
||||
import '../domain/recipe.dart';
|
||||
import '../domain/inventory_preview.dart';
|
||||
|
||||
class RecipeRepository {
|
||||
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,
|
||||
{String? token}) async {
|
||||
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 '../data/recipe_providers.dart';
|
||||
import '../domain/recipe.dart';
|
||||
import '../domain/inventory_preview.dart';
|
||||
|
||||
class RecipeDetailScreen extends ConsumerWidget {
|
||||
final int recipeId;
|
||||
@@ -182,9 +183,208 @@ class _RecipeBody extends StatelessWidget {
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(height: 1.6)),
|
||||
],
|
||||
_InventoryPreviewSection(recipeId: recipe.id),
|
||||
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