diff --git a/CLAUDE.md b/CLAUDE.md
index f843998..28e5b0f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,14 +74,16 @@ dev.ulfrx.recipe/
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
├── ui/
│ ├── theme/ # Colors, typography, Liquid glass style tokens
-│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
-│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
+│ ├── components/ # Shared, stateless (VM-free) Recipe-styled composables built on Compose Unstyled where useful
+│ └── screens/{recipes,planner,pantry,shopping,recipedetail}/ # Each with screen + ViewModel
├── data/{local,remote,repository}/
└── domain/ # Client-only logic; shared/ handles cross-cutting
```
**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above.
+**Rule:** A `screens/` package is a *stateful* UI feature (screen + ViewModel), not necessarily a nav route. `recipedetail` presents as a modal bottom sheet and is opened from multiple hosts (search, later planner) — it lives under `screens/` because it owns a ViewModel, while its leaf widgets (`IngredientRow`, `NutritionSummary`) stay in `components/`, which is reserved for stateless, VM-free composables.
+
## Non-negotiable conventions
1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor.
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 5469504..4315528 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -108,6 +108,9 @@ kotlin {
// ASWebAuthenticationSession integration directly from Kotlin.
implementation(libs.ktor.clientDarwin)
}
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ }
}
}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index b356e49..5ec1345 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -30,6 +30,27 @@
%1$d min
%1$d kcal
+
+ Wartości odżywcze
+ kcal
+ białko
+ tłuszcz
+ węglowodany
+ %1$dg
+
+
+ Zamień składnik
+
+
+ Zaplanuj
+ Porcje
+ Składniki
+ Kroki
+ %1$d.
+ Zmniejsz liczbę porcji
+ Zwiększ liczbę porcji
+ Przeciągnij, aby zamknąć szczegóły przepisu
+
Otwórz wyszukiwanie
Wyczyść i ukryj klawiaturę
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
index a9e8049..00ec737 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
@@ -5,6 +5,7 @@ import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
+import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
@@ -17,4 +18,5 @@ val shellModule =
viewModel()
viewModel()
viewModel()
+ viewModel()
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt
index 8089557..8889412 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt
@@ -45,9 +45,8 @@ import org.koin.compose.viewmodel.koinViewModel
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
* (`saveState=true`/`restoreState=true`).
*
- * Phase 5+ introduces detail screens with their own VM scopes; at that point
- * we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
- * specifically (passed via `entryDecorators = listOf(...)`).
+ * Recipe detail is a modal bottom sheet (not a nav destination), so it needs no
+ * per-entry VM scope here; its VM is hosted by the surface that opens it.
*
* ## Search note
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt
new file mode 100644
index 0000000..e2e3e3f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt
@@ -0,0 +1,283 @@
+package dev.ulfrx.recipe.ui.components.recipe
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.composables.icons.lucide.Check
+import com.composables.icons.lucide.Lucide
+import com.composables.icons.lucide.Shuffle
+import com.composeunstyled.UnstyledButton
+import com.composeunstyled.UnstyledIcon
+import dev.ulfrx.recipe.ui.theme.RecipeTheme
+import org.jetbrains.compose.resources.stringResource
+import recipe.composeapp.generated.resources.Res
+import recipe.composeapp.generated.resources.ingredient_substitute_a11y
+import kotlin.math.round
+
+data class RecipeIngredientOptionUi(
+ val id: String,
+ val name: String,
+ val amount: Double,
+ val unit: String,
+)
+
+data class RecipeIngredientSlotUi(
+ val default: RecipeIngredientOptionUi,
+ val alternatives: List = emptyList(),
+ val id: String = default.id,
+)
+
+@Composable
+fun IngredientRow(
+ slot: RecipeIngredientSlotUi,
+ modifier: Modifier = Modifier,
+ selectedOptionId: String = slot.default.id,
+ onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null,
+) {
+ val colors = RecipeTheme.colors
+ val typography = RecipeTheme.typography
+ val options = slot.options
+ val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default
+ val swappable = slot.alternatives.isNotEmpty() && onSelect != null
+ var expanded by remember(slot.id) { mutableStateOf(false) }
+
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(CornerRadius))
+ .background(colors.surface)
+ .animateContentSize(),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = PaddingHorizontal, vertical = PaddingVertical),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
+ ) {
+ BasicText(
+ text = selected.name,
+ style =
+ typography.body.copy(
+ color = colors.content,
+ fontWeight = FontWeight.Medium,
+ fontSize = NameTextSize,
+ lineHeight = LineHeight,
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ if (swappable) {
+ SwapToggle(onClick = { expanded = !expanded })
+ }
+ AmountLabel(amount = selected.amount, unit = selected.unit)
+ }
+
+ if (swappable && expanded) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = PaddingHorizontal, end = PaddingHorizontal, bottom = PaddingVertical),
+ verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
+ ) {
+ options.forEach { option ->
+ AlternativeOption(
+ option = option,
+ selected = option.id == selected.id,
+ onClick = {
+ onSelect(option)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AmountLabel(
+ amount: Double,
+ unit: String,
+) {
+ val colors = RecipeTheme.colors
+ val typography = RecipeTheme.typography
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
+ ) {
+ BasicText(
+ text = formatAmount(amount),
+ style =
+ typography.body.copy(
+ color = colors.content,
+ fontWeight = FontWeight.Medium,
+ fontSize = NameTextSize,
+ lineHeight = LineHeight,
+ ),
+ )
+ BasicText(
+ text = unit,
+ style =
+ typography.body.copy(
+ color = colors.contentMuted,
+ fontSize = UnitTextSize,
+ lineHeight = LineHeight,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun SwapToggle(onClick: () -> Unit) {
+ val colors = RecipeTheme.colors
+ UnstyledButton(
+ onClick = onClick,
+ modifier = Modifier.size(ToggleSize),
+ ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ UnstyledIcon(
+ imageVector = Lucide.Shuffle,
+ contentDescription = stringResource(Res.string.ingredient_substitute_a11y),
+ tint = colors.contentMuted,
+ modifier = Modifier.size(ToggleIconSize),
+ )
+ }
+ }
+}
+
+@Composable
+private fun AlternativeOption(
+ option: RecipeIngredientOptionUi,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val colors = RecipeTheme.colors
+ val typography = RecipeTheme.typography
+ UnstyledButton(
+ onClick = onClick,
+ backgroundColor = colors.background,
+ contentColor = colors.content,
+ shape = RoundedCornerShape(OptionCornerRadius),
+ contentPadding = PaddingValues(OptionPadding),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ BasicText(
+ text = option.name,
+ style =
+ typography.body.copy(
+ color = colors.content,
+ fontWeight = FontWeight.Medium,
+ fontSize = OptionNameTextSize,
+ lineHeight = OptionNameLineHeight,
+ ),
+ )
+ Spacer(Modifier.height(OptionMetaGap))
+ BasicText(
+ text = formatAmount(option.amount) + " " + option.unit,
+ style =
+ typography.body.copy(
+ color = colors.contentMuted,
+ fontSize = OptionMetaTextSize,
+ lineHeight = OptionMetaLineHeight,
+ ),
+ )
+ }
+ SelectionMark(selected = selected)
+ }
+ }
+}
+
+@Composable
+private fun SelectionMark(selected: Boolean) {
+ val colors = RecipeTheme.colors
+ Box(
+ modifier =
+ Modifier
+ .size(SelectionMarkSize)
+ .clip(RoundedCornerShape(percent = 50))
+ .border(
+ width = SelectionMarkBorder,
+ color = colors.separator,
+ shape = RoundedCornerShape(percent = 50),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (selected) {
+ UnstyledIcon(
+ imageVector = Lucide.Check,
+ contentDescription = null,
+ tint = colors.contentMuted,
+ modifier = Modifier.size(SelectionCheckSize),
+ )
+ }
+ }
+}
+
+private fun formatAmount(value: Double): String {
+ val scaled = round(value * 10.0).toLong()
+ val whole = scaled / 10
+ val frac = (scaled % 10).toInt()
+ return if (frac == 0) whole.toString() else "$whole,$frac"
+}
+
+internal val RecipeIngredientSlotUi.options: List
+ get() = listOf(default) + alternatives
+
+internal fun RecipeIngredientSlotUi.scaledBy(servings: Int) =
+ RecipeIngredientSlotUi(
+ default = default.copy(amount = default.amount * servings),
+ alternatives = alternatives.map { it.copy(amount = it.amount * servings) },
+ id = id,
+ )
+
+private val CornerRadius = 12.dp
+private val PaddingHorizontal = 12.dp
+private val PaddingVertical = 16.dp
+private val NameTextSize = 13.sp
+private val UnitTextSize = 12.sp
+private val LineHeight = 18.sp
+private val ToggleSize = 28.dp
+private val ToggleIconSize = 14.dp
+private val OptionCornerRadius = 10.dp
+private val OptionPadding = 12.dp
+private val OptionMetaGap = 2.dp
+private val OptionNameTextSize = 11.sp
+private val OptionNameLineHeight = 14.sp
+private val OptionMetaTextSize = 10.sp
+private val OptionMetaLineHeight = 13.sp
+private val SelectionMarkSize = 18.dp
+private val SelectionMarkBorder = 1.5.dp
+private val SelectionCheckSize = 10.dp
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt
new file mode 100644
index 0000000..293847b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt
@@ -0,0 +1,114 @@
+package dev.ulfrx.recipe.ui.components.recipe
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.ulfrx.recipe.ui.theme.RecipeTheme
+import org.jetbrains.compose.resources.stringResource
+import recipe.composeapp.generated.resources.Res
+import recipe.composeapp.generated.resources.nutrition_grams_format
+import recipe.composeapp.generated.resources.nutrition_macro_carbs
+import recipe.composeapp.generated.resources.nutrition_macro_fat
+import recipe.composeapp.generated.resources.nutrition_macro_kcal
+import recipe.composeapp.generated.resources.nutrition_macro_protein
+
+data class RecipeNutritionUi(
+ val kcal: Int,
+ val protein: Int,
+ val fat: Int,
+ val carbs: Int,
+)
+
+internal fun RecipeNutritionUi.scaledBy(servings: Int) =
+ RecipeNutritionUi(
+ kcal = kcal * servings,
+ protein = protein * servings,
+ fat = fat * servings,
+ carbs = carbs * servings,
+ )
+
+@Composable
+fun NutritionSummary(
+ nutrition: RecipeNutritionUi,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
+ ) {
+ MacroCard(
+ modifier = Modifier.weight(1f),
+ value = nutrition.kcal.toString(),
+ label = stringResource(Res.string.nutrition_macro_kcal),
+ )
+ MacroCard(
+ modifier = Modifier.weight(1f),
+ value = stringResource(Res.string.nutrition_grams_format, nutrition.protein),
+ label = stringResource(Res.string.nutrition_macro_protein),
+ )
+ MacroCard(
+ modifier = Modifier.weight(1f),
+ value = stringResource(Res.string.nutrition_grams_format, nutrition.fat),
+ label = stringResource(Res.string.nutrition_macro_fat),
+ )
+ MacroCard(
+ modifier = Modifier.weight(1f),
+ value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs),
+ label = stringResource(Res.string.nutrition_macro_carbs),
+ )
+ }
+}
+
+@Composable
+private fun MacroCard(
+ value: String,
+ label: String,
+ modifier: Modifier = Modifier,
+) {
+ val colors = RecipeTheme.colors
+ Column(
+ modifier =
+ modifier
+ .clip(RoundedCornerShape(CardCornerRadius))
+ .background(colors.surface)
+ .padding(vertical = RecipeTheme.spacing.sm, horizontal = RecipeTheme.spacing.xs),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ BasicText(
+ text = value,
+ style =
+ RecipeTheme.typography.body.copy(
+ color = colors.content,
+ fontWeight = FontWeight.Bold,
+ fontSize = ValueTextSize,
+ ),
+ )
+ Spacer(Modifier.height(RecipeTheme.spacing.xs))
+ BasicText(
+ text = label,
+ style =
+ RecipeTheme.typography.label.copy(
+ color = colors.contentMuted,
+ fontSize = LabelTextSize,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+ }
+}
+
+private val CardCornerRadius = 12.dp
+private val ValueTextSize = 16.sp
+private val LabelTextSize = 11.sp
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
new file mode 100644
index 0000000..f70f67a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
@@ -0,0 +1,509 @@
+package dev.ulfrx.recipe.ui.screens.recipedetail
+
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.composables.core.BottomSheetScope
+import com.composables.core.DragIndication
+import com.composables.core.ModalBottomSheet
+import com.composables.core.Scrim
+import com.composables.core.Sheet
+import com.composables.core.SheetDetent
+import com.composables.core.rememberModalBottomSheetState
+import com.composables.icons.lucide.Calendar
+import com.composables.icons.lucide.Clock
+import com.composables.icons.lucide.Lucide
+import com.composables.icons.lucide.Minus
+import com.composables.icons.lucide.Plus
+import com.composeunstyled.UnstyledButton
+import com.composeunstyled.UnstyledIcon
+import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
+import dev.ulfrx.recipe.ui.components.glass.GlassSurface
+import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
+import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
+import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
+import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
+import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
+import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
+import dev.ulfrx.recipe.ui.components.recipe.scaledBy
+import dev.ulfrx.recipe.ui.theme.RecipeTheme
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+import recipe.composeapp.generated.resources.Res
+import recipe.composeapp.generated.resources.nutrition_label
+import recipe.composeapp.generated.resources.recipe_card_minutes_format
+import recipe.composeapp.generated.resources.recipe_detail_handle_a11y
+import recipe.composeapp.generated.resources.recipe_detail_plan_button
+import recipe.composeapp.generated.resources.recipe_detail_section_ingredients
+import recipe.composeapp.generated.resources.recipe_detail_section_steps
+import recipe.composeapp.generated.resources.recipe_detail_servings_decrement_a11y
+import recipe.composeapp.generated.resources.recipe_detail_servings_increment_a11y
+import recipe.composeapp.generated.resources.recipe_detail_servings_label
+import recipe.composeapp.generated.resources.recipe_detail_step_number_format
+import recipe.composeapp.generated.resources.sample_recipe
+
+@Composable
+fun RecipeDetailSheet(
+ viewModel: RecipeDetailViewModel,
+ onPlanRecipe: (recipeId: String) -> Unit,
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val sheetState =
+ rememberModalBottomSheetState(
+ initialDetent = SheetDetent.Hidden,
+ detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
+ )
+ val ready = state as? RecipeDetailState.Ready
+
+ LaunchedEffect(ready != null) {
+ sheetState.targetDetent = if (ready != null) SheetDetent.FullyExpanded else SheetDetent.Hidden
+ }
+
+ // Only caller of dismiss(): a drag that settles the sheet at Hidden while the VM still holds
+ // the recipe. Programmatic closes must set targetDetent = Hidden and let this fire — calling
+ // dismiss() directly would clear the recipe mid-animation and blank the closing sheet.
+ LaunchedEffect(sheetState.isIdle, sheetState.currentDetent) {
+ if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && ready != null) {
+ viewModel.dismiss()
+ }
+ }
+
+ ModalBottomSheet(state = sheetState) {
+ Scrim(
+ scrimColor = ScrimColor,
+ enter = fadeIn(tween(ScrimFadeMillis)),
+ exit = fadeOut(tween(ScrimFadeMillis)),
+ )
+ Sheet(
+ modifier = Modifier.fillMaxWidth(),
+ backgroundColor = RecipeTheme.colors.background,
+ shape = RoundedCornerShape(topStart = SheetCornerRadius, topEnd = SheetCornerRadius),
+ ) {
+ ready?.let {
+ RecipeDetailContent(
+ ready = it,
+ onServingsChange = viewModel::setServings,
+ onSelectSubstitution = viewModel::selectSubstitution,
+ onPlanRecipe = onPlanRecipe,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BottomSheetScope.RecipeDetailContent(
+ ready: RecipeDetailState.Ready,
+ onServingsChange: (Int) -> Unit,
+ onSelectSubstitution: (String, String) -> Unit,
+ onPlanRecipe: (String) -> Unit,
+) {
+ val colors = RecipeTheme.colors
+ val typography = RecipeTheme.typography
+ val spacing = RecipeTheme.spacing
+ val scrollState = rememberScrollState()
+ val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ val handleLabel = stringResource(Res.string.recipe_detail_handle_a11y)
+
+ val backdrop = rememberGlassBackdropState()
+
+ val detail = ready.recipe
+ val servings = ready.servings
+
+ CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
+ Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) {
+ GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
+ RecipeHero()
+
+ Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
+ BasicText(
+ text = detail.title,
+ style =
+ typography.display.copy(
+ color = colors.content,
+ fontSize = TitleTextSize,
+ lineHeight = TitleLineHeight,
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+ Spacer(Modifier.height(spacing.sm))
+ MetaRow(minutes = detail.cookingMinutes)
+
+ Spacer(Modifier.height(spacing.xl))
+ NutritionSection(nutrition = detail.nutrition.scaledBy(servings))
+
+ Spacer(Modifier.height(spacing.xl))
+ ServingsSection(servings = servings, onServingsChange = onServingsChange)
+
+ Spacer(Modifier.height(spacing.xl))
+ IngredientsSection(
+ ingredients = detail.ingredients,
+ servings = servings,
+ substitutions = ready.substitutions,
+ onSelectSubstitution = onSelectSubstitution,
+ )
+
+ Spacer(Modifier.height(spacing.xl))
+ StepsSection(steps = detail.steps)
+
+ Spacer(Modifier.height(bottomInset + spacing.xxl))
+ }
+ }
+ }
+
+ DragIndication(
+ modifier =
+ Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = spacing.sm)
+ .semantics { contentDescription = handleLabel }
+ .clip(RoundedCornerShape(percent = 50))
+ .background(colors.surface.copy(alpha = 0.85f))
+ .width(HandleWidth)
+ .height(HandleHeight),
+ )
+
+ PlanButton(
+ modifier =
+ Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = spacing.lg, end = spacing.lg),
+ onClick = { onPlanRecipe(detail.id) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun RecipeHero() {
+ val colors = RecipeTheme.colors
+ Box(modifier = Modifier.fillMaxWidth().height(HeroHeight)) {
+ Box(modifier = Modifier.fillMaxSize().background(colors.surfaceGlass))
+ Image(
+ painter = painterResource(Res.drawable.sample_recipe),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colorStops =
+ arrayOf(
+ 0.5f to Color.Transparent,
+ 1f to colors.background,
+ ),
+ ),
+ ),
+ )
+ }
+}
+
+@Composable
+private fun MetaRow(minutes: Int) {
+ val colors = RecipeTheme.colors
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
+ ) {
+ UnstyledIcon(
+ imageVector = Lucide.Clock,
+ contentDescription = null,
+ tint = colors.contentMuted,
+ modifier = Modifier.size(MetaIconSize),
+ )
+ BasicText(
+ text = stringResource(Res.string.recipe_card_minutes_format, minutes),
+ style = RecipeTheme.typography.label.copy(color = colors.contentMuted),
+ )
+ }
+}
+
+@Composable
+private fun Section(
+ title: String,
+ content: @Composable () -> Unit,
+) {
+ SectionTitle(text = title)
+ Spacer(Modifier.height(RecipeTheme.spacing.lg))
+ content()
+}
+
+@Composable
+private fun SectionTitle(text: String) {
+ BasicText(
+ text = text.uppercase(),
+ style =
+ RecipeTheme.typography.label.copy(
+ color = RecipeTheme.colors.contentMuted,
+ fontSize = SectionHeaderTextSize,
+ letterSpacing = SectionHeaderTracking,
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+}
+
+@Composable
+private fun NutritionSection(nutrition: RecipeNutritionUi) {
+ Section(title = stringResource(Res.string.nutrition_label)) {
+ NutritionSummary(nutrition = nutrition)
+ }
+}
+
+@Composable
+private fun ServingsSection(
+ servings: Int,
+ onServingsChange: (Int) -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ SectionTitle(text = stringResource(Res.string.recipe_detail_servings_label))
+ ServingsStepper(
+ servings = servings,
+ onDecrement = { onServingsChange(servings - 1) },
+ onIncrement = { onServingsChange(servings + 1) },
+ )
+ }
+}
+
+@Composable
+private fun IngredientsSection(
+ ingredients: List,
+ servings: Int,
+ substitutions: Map,
+ onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
+) {
+ Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
+ Column(verticalArrangement = Arrangement.spacedBy(IngredientRowGap)) {
+ ingredients.forEach { slot ->
+ IngredientRow(
+ slot = slot.scaledBy(servings),
+ selectedOptionId = substitutions[slot.id] ?: slot.default.id,
+ onSelect =
+ if (slot.alternatives.isNotEmpty()) {
+ { choice -> onSelectSubstitution(slot.id, choice.id) }
+ } else {
+ null
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun StepsSection(steps: List) {
+ Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
+ Column(verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
+ steps.forEachIndexed { index, step ->
+ StepRow(number = index + 1, text = step)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ServingsStepper(
+ servings: Int,
+ onDecrement: () -> Unit,
+ onIncrement: () -> Unit,
+) {
+ val colors = RecipeTheme.colors
+ Row(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(percent = 50))
+ .background(colors.surface)
+ .padding(horizontal = RecipeTheme.spacing.xs),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
+ ) {
+ StepperButton(
+ icon = Lucide.Minus,
+ contentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
+ enabled = servings > MIN_RECIPE_SERVINGS,
+ onClick = onDecrement,
+ )
+ BasicText(
+ text = servings.toString(),
+ style =
+ RecipeTheme.typography.body.copy(
+ color = colors.content,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ modifier = Modifier.width(ServingsValueWidth),
+ )
+ StepperButton(
+ icon = Lucide.Plus,
+ contentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
+ enabled = servings < MAX_RECIPE_SERVINGS,
+ onClick = onIncrement,
+ )
+ }
+}
+
+@Composable
+private fun StepperButton(
+ icon: ImageVector,
+ contentDescription: String,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ val colors = RecipeTheme.colors
+ UnstyledButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier.size(StepperButtonSize),
+ ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ UnstyledIcon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f),
+ modifier = Modifier.size(StepperIconSize),
+ )
+ }
+ }
+}
+
+@Composable
+private fun StepRow(
+ number: Int,
+ text: String,
+) {
+ val colors = RecipeTheme.colors
+ Row(horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
+ BasicText(
+ text = stringResource(Res.string.recipe_detail_step_number_format, number),
+ style =
+ RecipeTheme.typography.body.copy(
+ color = colors.contentMuted,
+ fontWeight = FontWeight.Medium,
+ fontSize = StepTextSize,
+ ),
+ modifier = Modifier.width(StepNumberWidth),
+ )
+ BasicText(
+ text = text,
+ style =
+ RecipeTheme.typography.body.copy(
+ color = colors.content,
+ fontWeight = FontWeight.Normal,
+ fontSize = StepTextSize,
+ lineHeight = StepLineHeight,
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ }
+}
+
+@Composable
+private fun PlanButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = RecipeTheme.colors
+ GlassSurface(
+ modifier = modifier.height(PlanButtonHeight),
+ cornerRadius = PlanButtonHeight / 2,
+ tint = colors.surfaceGlass,
+ ) {
+ UnstyledButton(
+ onClick = onClick,
+ backgroundColor = Color.Transparent,
+ contentColor = colors.content,
+ contentPadding = PaddingValues(horizontal = RecipeTheme.spacing.lg),
+ modifier = Modifier.fillMaxHeight(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
+ ) {
+ UnstyledIcon(
+ imageVector = Lucide.Calendar,
+ contentDescription = null,
+ tint = colors.content,
+ modifier = Modifier.size(PlanButtonIconSize),
+ )
+ BasicText(
+ text = stringResource(Res.string.recipe_detail_plan_button),
+ style =
+ RecipeTheme.typography.label.copy(
+ color = colors.content,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ }
+ }
+ }
+}
+
+private const val SHEET_HEIGHT_FRACTION = 0.92f
+private const val ScrimFadeMillis = 250
+
+private val ScrimColor = Color.Black.copy(alpha = 0.45f)
+private val SheetCornerRadius = 28.dp
+private val HeroHeight = 200.dp
+private val HandleWidth = 36.dp
+private val HandleHeight = 5.dp
+private val IngredientRowGap = 6.dp
+private val MetaIconSize = 14.dp
+private val StepperButtonSize = 30.dp
+private val StepperIconSize = 14.dp
+private val ServingsValueWidth = 28.dp
+private val StepNumberWidth = 20.dp
+private val PlanButtonHeight = 36.dp
+private val PlanButtonIconSize = 14.dp
+
+private val TitleTextSize = 24.sp
+private val TitleLineHeight = 28.sp
+private val SectionHeaderTextSize = 11.sp
+private val SectionHeaderTracking = 1.sp
+private val StepTextSize = 14.sp
+private val StepLineHeight = 20.sp
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt
new file mode 100644
index 0000000..93447c4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt
@@ -0,0 +1,14 @@
+package dev.ulfrx.recipe.ui.screens.recipedetail
+
+internal const val MIN_RECIPE_SERVINGS = 1
+internal const val MAX_RECIPE_SERVINGS = 12
+
+sealed interface RecipeDetailState {
+ data object Hidden : RecipeDetailState
+
+ data class Ready(
+ val recipe: RecipeDetailUi,
+ val servings: Int = MIN_RECIPE_SERVINGS,
+ val substitutions: Map = emptyMap(),
+ ) : RecipeDetailState
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt
new file mode 100644
index 0000000..3884720
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt
@@ -0,0 +1,13 @@
+package dev.ulfrx.recipe.ui.screens.recipedetail
+
+import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
+import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
+
+data class RecipeDetailUi(
+ val id: String,
+ val title: String,
+ val cookingMinutes: Int,
+ val nutrition: RecipeNutritionUi,
+ val ingredients: List,
+ val steps: List,
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt
new file mode 100644
index 0000000..b3af8b6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt
@@ -0,0 +1,47 @@
+package dev.ulfrx.recipe.ui.screens.recipedetail
+
+import androidx.lifecycle.ViewModel
+import dev.ulfrx.recipe.ui.components.recipe.options
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+class RecipeDetailViewModel : ViewModel() {
+ private val _state = MutableStateFlow(RecipeDetailState.Hidden)
+ val state: StateFlow = _state.asStateFlow()
+
+ fun open(recipeId: String) {
+ _state.value = sampleRecipeDetail(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.Hidden
+ }
+
+ fun dismiss() {
+ _state.value = RecipeDetailState.Hidden
+ }
+
+ fun setServings(value: Int) =
+ _state.update { current ->
+ if (current is RecipeDetailState.Ready) {
+ current.copy(servings = value.coerceIn(MIN_RECIPE_SERVINGS, MAX_RECIPE_SERVINGS))
+ } else {
+ current
+ }
+ }
+
+ fun selectSubstitution(
+ slotId: String,
+ optionId: String,
+ ) = _state.update { current ->
+ if (current !is RecipeDetailState.Ready) return@update current
+ val slot = current.recipe.ingredients.firstOrNull { it.id == slotId } ?: return@update current
+ if (slot.options.none { it.id == optionId }) return@update current
+
+ val substitutions =
+ if (optionId == slot.default.id) {
+ current.substitutions - slotId
+ } else {
+ current.substitutions + (slotId to optionId)
+ }
+ current.copy(substitutions = substitutions)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt
new file mode 100644
index 0000000..c7e286c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt
@@ -0,0 +1,400 @@
+package dev.ulfrx.recipe.ui.screens.recipedetail
+
+import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
+import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
+import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
+
+internal val sampleRecipeDetails: Map =
+ listOf(
+ RecipeDetailUi(
+ id = "rcp_nalesniki",
+ title = "Naleśniki z twarogiem",
+ cookingMinutes = 25,
+ nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42),
+ ingredients =
+ listOf(
+ slot("Mąka pszenna", 60.0, "g"),
+ slot("Mleko", 125.0, "ml"),
+ slot("Jajka", 1.0, "szt."),
+ slot(
+ "Twaróg półtłusty",
+ 100.0,
+ "g",
+ alt("Twaróg chudy", 100.0, "g"),
+ alt("Serek wiejski", 120.0, "g"),
+ ),
+ slot("Miód", 10.0, "g", alt("Syrop klonowy", 12.0, "g")),
+ slot("Olej do smażenia", 5.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Zmiksuj mąkę, mleko, jajko i szczyptę soli na gładkie ciasto. Odstaw na 10 minut.",
+ "Rozgrzej odrobinę oleju na patelni i smaż cienkie naleśniki z obu stron na złoto.",
+ "Twaróg rozetrzyj z miodem na gładką masę.",
+ "Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_owsianka",
+ title = "Owsianka z owocami i orzechami",
+ cookingMinutes = 10,
+ nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38),
+ ingredients =
+ listOf(
+ slot("Płatki owsiane", 50.0, "g"),
+ slot(
+ "Mleko",
+ 200.0,
+ "ml",
+ alt("Napój owsiany", 200.0, "ml"),
+ alt("Napój migdałowy", 200.0, "ml"),
+ ),
+ slot("Banan", 0.5, "szt."),
+ slot(
+ "Borówki",
+ 40.0,
+ "g",
+ alt("Maliny", 40.0, "g"),
+ alt("Truskawki", 50.0, "g"),
+ ),
+ slot(
+ "Orzechy włoskie",
+ 15.0,
+ "g",
+ alt("Migdały", 15.0, "g"),
+ alt("Orzechy laskowe", 15.0, "g"),
+ ),
+ slot("Miód", 10.0, "g"),
+ ),
+ steps =
+ listOf(
+ "Płatki owsiane zalej mlekiem i gotuj na małym ogniu 4–5 minut, mieszając.",
+ "Przełóż owsiankę do miski.",
+ "Ułóż na wierzchu pokrojonego banana, borówki i posiekane orzechy.",
+ "Polej miodem i podawaj.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_spaghetti",
+ title = "Spaghetti bolognese",
+ cookingMinutes = 40,
+ nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65),
+ ingredients =
+ listOf(
+ slot("Makaron spaghetti", 100.0, "g"),
+ slot(
+ "Mięso mielone wołowe",
+ 120.0,
+ "g",
+ alt("Mięso mielone z indyka", 120.0, "g"),
+ alt("Soczewica czerwona", 60.0, "g"),
+ ),
+ slot("Passata pomidorowa", 150.0, "ml"),
+ slot("Cebula", 0.5, "szt."),
+ slot("Czosnek", 1.0, "ząbek"),
+ slot("Oliwa", 10.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Makaron ugotuj al dente w osolonej wodzie wg opakowania.",
+ "Na oliwie zeszklij posiekaną cebulę i czosnek, dodaj mięso i smaż do zrumienienia.",
+ "Wlej passatę, dopraw solą, pieprzem i ziołami. Duś 15 minut.",
+ "Wymieszaj sos z odsączonym makaronem i podawaj.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_pierogi",
+ title = "Pierogi ruskie",
+ cookingMinutes = 90,
+ nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68),
+ ingredients =
+ listOf(
+ slot("Mąka pszenna", 120.0, "g"),
+ slot("Woda", 60.0, "ml"),
+ slot("Ziemniaki", 150.0, "g"),
+ slot("Twaróg półtłusty", 80.0, "g"),
+ slot("Cebula", 0.5, "szt."),
+ slot("Masło", 10.0, "g"),
+ ),
+ steps =
+ listOf(
+ "Z mąki, ciepłej wody i szczypty soli zagnieć gładkie ciasto. Odstaw pod ściereczką.",
+ "Ziemniaki ugotuj i ugnieć z twarogiem. Dodaj zeszkloną cebulę, dopraw solą i pieprzem.",
+ "Rozwałkuj ciasto, wykrawaj krążki, nakładaj farsz i zlepiaj pierogi.",
+ "Gotuj partiami w osolonej wodzie 3–4 minuty od wypłynięcia.",
+ "Podawaj okraszone masłem i podsmażoną cebulą.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_kanapka_awokado",
+ title = "Kanapka z awokado i jajkiem",
+ cookingMinutes = 5,
+ nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16),
+ ingredients =
+ listOf(
+ slot("Pieczywo razowe", 1.0, "kromka"),
+ slot("Awokado", 0.5, "szt."),
+ slot("Jajko", 1.0, "szt."),
+ slot("Sok z cytryny", 5.0, "ml"),
+ slot("Szczypiorek", 5.0, "g"),
+ ),
+ steps =
+ listOf(
+ "Jajko ugotuj na twardo (ok. 9 minut), ostudź i obierz.",
+ "Awokado rozgnieć widelcem z sokiem z cytryny, solą i pieprzem.",
+ "Posmaruj kromkę pastą z awokado.",
+ "Ułóż plastry jajka i posyp szczypiorkiem.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_schabowy",
+ title = "Schabowy z ziemniakami",
+ cookingMinutes = 60,
+ nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62),
+ ingredients =
+ listOf(
+ slot("Schab", 150.0, "g"),
+ slot("Jajko", 1.0, "szt."),
+ slot("Bułka tarta", 40.0, "g"),
+ slot("Mąka pszenna", 20.0, "g"),
+ slot("Ziemniaki", 300.0, "g"),
+ slot("Olej do smażenia", 30.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Ziemniaki obierz i ugotuj w osolonej wodzie.",
+ "Schab rozbij na cienkie kotlety, dopraw solą i pieprzem.",
+ "Panieruj kolejno w mące, rozkłóconym jajku i bułce tartej.",
+ "Smaż na rozgrzanym oleju z obu stron na złoto.",
+ "Podawaj z ziemniakami i ulubioną surówką.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_salatka_grecka",
+ title = "Sałatka grecka",
+ cookingMinutes = 15,
+ nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12),
+ ingredients =
+ listOf(
+ slot("Pomidory", 150.0, "g"),
+ slot("Ogórek", 0.5, "szt."),
+ slot("Papryka czerwona", 0.5, "szt."),
+ slot("Ser feta", 60.0, "g", alt("Ser sałatkowy", 60.0, "g")),
+ slot("Oliwki czarne", 30.0, "g"),
+ slot("Oliwa", 15.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Pomidory, ogórka i paprykę pokrój w grubą kostkę.",
+ "Przełóż warzywa do miski, dodaj oliwki.",
+ "Pokrusz fetę na wierzch.",
+ "Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_pomidorowa",
+ title = "Zupa pomidorowa z ryżem",
+ cookingMinutes = 35,
+ nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39),
+ ingredients =
+ listOf(
+ slot("Passata pomidorowa", 200.0, "ml"),
+ slot("Bulion warzywny", 400.0, "ml"),
+ slot("Ryż", 40.0, "g"),
+ slot("Marchewka", 1.0, "szt."),
+ slot("Śmietana 18%", 20.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Ryż ugotuj osobno do miękkości.",
+ "W garnku zagotuj bulion ze startą marchewką, gotuj 10 minut.",
+ "Wlej passatę i gotuj kolejne 10 minut. Dopraw solą i cukrem.",
+ "Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_kurczak_curry",
+ title = "Kurczak curry z ryżem basmati",
+ cookingMinutes = 45,
+ nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70),
+ ingredients =
+ listOf(
+ slot("Pierś z kurczaka", 150.0, "g"),
+ slot("Ryż basmati", 80.0, "g"),
+ slot("Mleko kokosowe", 120.0, "ml", alt("Śmietanka 18%", 120.0, "ml")),
+ slot("Pasta curry", 20.0, "g"),
+ slot("Cebula", 0.5, "szt."),
+ slot("Olej", 10.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Ryż basmati ugotuj wg opakowania.",
+ "Kurczaka pokrój w kostkę i obsmaż na oleju z posiekaną cebulą.",
+ "Dodaj pastę curry, smaż minutę, wlej mleko kokosowe.",
+ "Duś 12–15 minut do zgęstnienia sosu. Dopraw solą.",
+ "Podawaj z ryżem basmati.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_jajecznica",
+ title = "Jajecznica na maśle ze szczypiorkiem",
+ cookingMinutes = 8,
+ nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3),
+ ingredients =
+ listOf(
+ slot("Jajka", 3.0, "szt."),
+ slot("Masło", 10.0, "g"),
+ slot("Szczypiorek", 10.0, "g"),
+ ),
+ steps =
+ listOf(
+ "Rozpuść masło na patelni na małym ogniu.",
+ "Wbij jajka i smaż, delikatnie mieszając, do ścięcia.",
+ "Dopraw solą i pieprzem.",
+ "Posyp posiekanym szczypiorkiem i podawaj.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_risotto",
+ title = "Risotto z grzybami leśnymi",
+ cookingMinutes = 35,
+ nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66),
+ ingredients =
+ listOf(
+ slot("Ryż arborio", 80.0, "g"),
+ slot("Grzyby leśne", 100.0, "g"),
+ slot("Bulion warzywny", 350.0, "ml"),
+ slot("Cebula", 0.5, "szt."),
+ slot("Parmezan", 20.0, "g"),
+ slot("Masło", 15.0, "g"),
+ ),
+ steps =
+ listOf(
+ "Na maśle zeszklij posiekaną cebulę, dodaj grzyby i podsmaż.",
+ "Wsyp ryż i smaż minutę, aż stanie się szklisty.",
+ "Dolewaj ciepły bulion po chochli, mieszając, aż ryż go wchłonie.",
+ "Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_tortilla",
+ title = "Tortilla z kurczakiem i warzywami",
+ cookingMinutes = 20,
+ nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48),
+ ingredients =
+ listOf(
+ slot("Tortilla pszenna", 1.0, "szt."),
+ slot("Pierś z kurczaka", 120.0, "g"),
+ slot("Papryka", 0.5, "szt."),
+ slot("Sałata", 30.0, "g"),
+ slot("Sos jogurtowy", 30.0, "g"),
+ slot("Olej", 5.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Kurczaka pokrój w paski, dopraw i obsmaż na oleju.",
+ "Paprykę pokrój w cienkie paski.",
+ "Tortillę podgrzej na suchej patelni.",
+ "Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_smoothie",
+ title = "Smoothie bananowo-szpinakowe",
+ cookingMinutes = 5,
+ nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33),
+ ingredients =
+ listOf(
+ slot("Banan", 1.0, "szt."),
+ slot("Szpinak świeży", 30.0, "g"),
+ slot(
+ "Jogurt naturalny",
+ 100.0,
+ "g",
+ alt("Skyr", 100.0, "g"),
+ alt("Kefir", 120.0, "g"),
+ ),
+ slot("Mleko", 100.0, "ml", alt("Napój owsiany", 100.0, "ml")),
+ ),
+ steps =
+ listOf(
+ "Wszystkie składniki umieść w blenderze.",
+ "Miksuj do uzyskania gładkiej konsystencji.",
+ "Przelej do szklanki i podawaj od razu.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_losos",
+ title = "Łosoś pieczony z brokułami",
+ cookingMinutes = 30,
+ nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12),
+ ingredients =
+ listOf(
+ slot("Filet z łososia", 150.0, "g"),
+ slot("Brokuł", 200.0, "g"),
+ slot("Oliwa", 15.0, "ml"),
+ slot("Cytryna", 0.5, "szt."),
+ slot("Czosnek", 1.0, "ząbek"),
+ ),
+ steps =
+ listOf(
+ "Piekarnik nagrzej do 200°C.",
+ "Łososia skrop oliwą i sokiem z cytryny, dopraw solą i pieprzem.",
+ "Brokuł podziel na różyczki, wymieszaj z oliwą i czosnkiem.",
+ "Piecz łososia i brokuły na blasze ok. 15–18 minut.",
+ ),
+ ),
+ RecipeDetailUi(
+ id = "rcp_nadziewane_papryki",
+ title = "Papryki nadziewane kaszą i warzywami",
+ cookingMinutes = 55,
+ nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58),
+ ingredients =
+ listOf(
+ slot("Papryka", 2.0, "szt."),
+ slot("Kasza jaglana", 60.0, "g"),
+ slot("Cukinia", 80.0, "g"),
+ slot("Passata pomidorowa", 100.0, "ml"),
+ slot("Cebula", 0.5, "szt."),
+ slot("Oliwa", 10.0, "ml"),
+ ),
+ steps =
+ listOf(
+ "Kaszę jaglaną ugotuj do miękkości.",
+ "Na oliwie podsmaż cebulę i pokrojoną cukinię.",
+ "Wymieszaj kaszę z warzywami i połową passaty. Dopraw.",
+ "Papryki przekrój, oczyść i napełnij farszem.",
+ "Polej resztą passaty i piecz w 190°C ok. 30 minut.",
+ ),
+ ),
+ ).associateBy { it.id }
+
+internal fun sampleRecipeDetail(id: String): RecipeDetailUi? = sampleRecipeDetails[id]
+
+private fun slot(
+ name: String,
+ amount: Double,
+ unit: String,
+ vararg alternatives: RecipeIngredientOptionUi,
+) = RecipeIngredientSlotUi(
+ default = option(name, amount, unit),
+ alternatives = alternatives.toList(),
+ id = "sample-slot:$name:$amount:$unit",
+)
+
+private fun option(
+ name: String,
+ amount: Double,
+ unit: String,
+) = RecipeIngredientOptionUi(
+ id = "sample:$name",
+ name = name,
+ amount = amount,
+ unit = unit,
+)
+
+private fun alt(
+ name: String,
+ amount: Double,
+ unit: String,
+) = option(name, amount, unit)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
index 468ec45..87bed4c 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
@@ -17,6 +17,8 @@ import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
+import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
+import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -27,7 +29,9 @@ import recipe.composeapp.generated.resources.search_screen_empty_results_title
fun SearchScreen(
viewModel: ShellSearchViewModel,
catalogViewModel: RecipeCatalogViewModel,
+ detailViewModel: RecipeDetailViewModel,
catalogGridState: LazyGridState,
+ onPlanRecipe: (String) -> Unit = {},
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
@@ -55,10 +59,15 @@ fun SearchScreen(
} else {
RecipeCatalogGrid(
state = catalogState,
- onRecipeClick = {},
+ onRecipeClick = detailViewModel::open,
gridState = catalogGridState,
modifier = Modifier.fillMaxSize(),
)
}
+
+ RecipeDetailSheet(
+ viewModel = detailViewModel,
+ onPlanRecipe = onPlanRecipe,
+ )
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt
index e2ff51a..c67f85f 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt
@@ -3,6 +3,6 @@ package dev.ulfrx.recipe.ui.screens.search.catalog
data class RecipeCardUi(
val id: String,
val title: String,
- val minutes: Int,
+ val cookingMinutes: Int,
val kcal: Int,
)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt
index 07a82bc..1ced103 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt
@@ -166,7 +166,7 @@ private fun RecipeMetaRow(card: RecipeCardUi) {
) {
MetaItem(
icon = Lucide.Clock,
- label = stringResource(Res.string.recipe_card_minutes_format, card.minutes),
+ label = stringResource(Res.string.recipe_card_minutes_format, card.cookingMinutes),
)
MetaItem(
icon = Lucide.Flame,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt
index 9feddea..a64048d 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt
@@ -5,91 +5,91 @@ internal val sampleRecipeCatalogCards: List =
RecipeCardUi(
id = "rcp_nalesniki",
title = "Naleśniki z twarogiem",
- minutes = 25,
+ cookingMinutes = 25,
kcal = 320,
),
RecipeCardUi(
id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami",
- minutes = 10,
+ cookingMinutes = 10,
kcal = 280,
),
RecipeCardUi(
id = "rcp_spaghetti",
title = "Spaghetti bolognese",
- minutes = 40,
+ cookingMinutes = 40,
kcal = 540,
),
RecipeCardUi(
id = "rcp_pierogi",
title = "Pierogi ruskie",
- minutes = 90,
+ cookingMinutes = 90,
kcal = 460,
),
RecipeCardUi(
id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem",
- minutes = 5,
+ cookingMinutes = 5,
kcal = 210,
),
RecipeCardUi(
id = "rcp_schabowy",
title = "Schabowy z ziemniakami",
- minutes = 60,
+ cookingMinutes = 60,
kcal = 720,
),
RecipeCardUi(
id = "rcp_salatka_grecka",
title = "Sałatka grecka",
- minutes = 15,
+ cookingMinutes = 15,
kcal = 310,
),
RecipeCardUi(
id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem",
- minutes = 35,
+ cookingMinutes = 35,
kcal = 240,
),
RecipeCardUi(
id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati",
- minutes = 45,
+ cookingMinutes = 45,
kcal = 580,
),
RecipeCardUi(
id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem",
- minutes = 8,
+ cookingMinutes = 8,
kcal = 290,
),
RecipeCardUi(
id = "rcp_risotto",
title = "Risotto z grzybami leśnymi",
- minutes = 35,
+ cookingMinutes = 35,
kcal = 470,
),
RecipeCardUi(
id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami",
- minutes = 20,
+ cookingMinutes = 20,
kcal = 430,
),
RecipeCardUi(
id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe",
- minutes = 5,
+ cookingMinutes = 5,
kcal = 180,
),
RecipeCardUi(
id = "rcp_losos",
title = "Łosoś pieczony z brokułami",
- minutes = 30,
+ cookingMinutes = 30,
kcal = 510,
),
RecipeCardUi(
id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami",
- minutes = 55,
+ cookingMinutes = 55,
kcal = 390,
),
)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
index 3af5c98..1ad1fe4 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
@@ -26,6 +26,7 @@ import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
+import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel
@@ -35,6 +36,7 @@ fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel()
val catalogVm: RecipeCatalogViewModel = koinViewModel()
+ val detailVm: RecipeDetailViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState()
@@ -63,6 +65,7 @@ fun AppShell(modifier: Modifier = Modifier) {
SearchScreen(
viewModel = searchVm,
catalogViewModel = catalogVm,
+ detailViewModel = detailVm,
catalogGridState = catalogGridState,
)
} else {
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
index 60ec7a1..2f61f75 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
@@ -17,12 +17,12 @@ data object RecipeGlass {
val dockPress: RecipeGlassStyle =
RecipeGlassStyle(
- refraction = 0.20f,
- curve = 0.05f,
+ refraction = 0.05f,
+ curve = 0.25f,
edge = 0.04f,
- dispersion = 0.03f,
- saturation = 0.6f,
- contrast = 1.8f,
+ dispersion = 0.0f,
+ saturation = 1.0f,
+ contrast = 1.0f,
frost = 0.dp,
)
}
diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModelTest.kt
new file mode 100644
index 0000000..fe88398
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModelTest.kt
@@ -0,0 +1,81 @@
+package dev.ulfrx.recipe.ui.screens.recipedetail
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class RecipeDetailViewModelTest {
+ @Test
+ fun openKnownRecipeShowsFreshState() {
+ val viewModel = RecipeDetailViewModel()
+
+ viewModel.open("rcp_nalesniki")
+
+ val state = viewModel.readyState()
+ assertEquals("rcp_nalesniki", state.recipe.id)
+ assertEquals(MIN_RECIPE_SERVINGS, state.servings)
+ assertTrue(state.substitutions.isEmpty())
+ }
+
+ @Test
+ fun openUnknownRecipeClearsPreviousState() {
+ val viewModel = RecipeDetailViewModel()
+
+ viewModel.open("rcp_nalesniki")
+ viewModel.open("missing")
+
+ assertEquals(RecipeDetailState.Hidden, viewModel.state.value)
+ }
+
+ @Test
+ fun servingsAreClampedToSupportedRange() {
+ val viewModel = RecipeDetailViewModel()
+ viewModel.open("rcp_nalesniki")
+
+ viewModel.setServings(0)
+ assertEquals(MIN_RECIPE_SERVINGS, viewModel.readyState().servings)
+
+ viewModel.setServings(MAX_RECIPE_SERVINGS + 1)
+ assertEquals(MAX_RECIPE_SERVINGS, viewModel.readyState().servings)
+ }
+
+ @Test
+ fun substitutionCanBeSelectedAndResetToDefault() {
+ val viewModel = RecipeDetailViewModel()
+ viewModel.open("rcp_nalesniki")
+ val slot =
+ viewModel
+ .readyState()
+ .recipe.ingredients
+ .first { it.alternatives.isNotEmpty() }
+ val alternative = slot.alternatives.first()
+
+ viewModel.selectSubstitution(slot.id, alternative.id)
+ assertEquals(alternative.id, viewModel.readyState().substitutions[slot.id])
+
+ viewModel.selectSubstitution(slot.id, slot.default.id)
+ assertFalse(slot.id in viewModel.readyState().substitutions)
+ }
+
+ @Test
+ fun invalidSubstitutionIsIgnored() {
+ val viewModel = RecipeDetailViewModel()
+ viewModel.open("rcp_nalesniki")
+ val slot =
+ viewModel
+ .readyState()
+ .recipe.ingredients
+ .first { it.alternatives.isNotEmpty() }
+
+ viewModel.selectSubstitution(slot.id, "missing")
+
+ assertTrue(viewModel.readyState().substitutions.isEmpty())
+ }
+
+ private fun RecipeDetailViewModel.readyState(): RecipeDetailState.Ready {
+ val state = state.value
+ assertTrue(state is RecipeDetailState.Ready)
+ return state
+ }
+}