Reorganise bottom sheet, recipe detail and meal plan editor

This commit is contained in:
2026-06-05 23:16:05 +02:00
parent bcd9b329c5
commit 11ea98e452
26 changed files with 760 additions and 921 deletions

View File

@@ -93,6 +93,7 @@ kotlin {
implementation(libs.multiplatform.settings)
implementation(libs.lokksmith.compose)
implementation(libs.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodelNavigation3)
implementation(libs.compose.unstyled)
implementation(libs.compose.icons.lucide)
implementation(libs.liquid)

View File

@@ -48,7 +48,9 @@
<string name="recipe_detail_step_number_format">%1$d.</string>
<string name="recipe_detail_servings_decrement_a11y">Zmniejsz liczbę porcji</string>
<string name="recipe_detail_servings_increment_a11y">Zwiększ liczbę porcji</string>
<string name="recipe_detail_handle_a11y">Przeciągnij, aby zamknąć szczegóły przepisu</string>
<string name="sheet_drag_handle_a11y">Przeciągnij w dół, aby zamknąć</string>
<string name="recipe_detail_not_found">Nie znaleziono przepisu</string>
<string name="meal_plan_editor_not_found">Nie udało się otworzyć edytora</string>
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string>

View File

@@ -1,13 +1,16 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.sampleRecipe
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
@@ -19,6 +22,16 @@ val shellModule =
viewModel<ShoppingViewModel>()
viewModel<ShellSearchViewModel>()
viewModel<RecipeCatalogViewModel>()
viewModel<RecipeDetailViewModel>()
viewModel<MealPlanEditorViewModel>()
viewModel { (recipeId: String) ->
RecipeDetailViewModel(recipeId = recipeId)
}
viewModel { (source: MealPlanEditorSource) ->
MealPlanEditorViewModel(
source = source,
recipeProvider = ::sampleRecipe,
// Phase 6 swaps this for the real PlannedMealsRepository lookup.
plannedMealProvider = { null },
)
}
}

View File

@@ -0,0 +1,16 @@
package dev.ulfrx.recipe.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed interface MealPlanEditorSource {
@Serializable
data class NewFromRecipe(
val recipeId: String,
val initialServings: Int = 1,
val initialSubstitutions: Map<String, String> = emptyMap(),
) : MealPlanEditorSource
@Serializable
data class EditExistingPlan(val plannedMealId: String) : MealPlanEditorSource
}

View File

@@ -4,16 +4,9 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
/**
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
* back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration).
*
* Screens are grouped by tab so future detail destinations slot in without
* polluting the top-level namespace — e.g. `Screen.Pantry.Detail(id)`. The
* grouping is purely a code-organisation convenience; Nav 3 treats each leaf as
* an independent NavKey regardless of nesting.
*
* The Recipes catalog has no own tab — it is reached via the shell-wide search
* destination (see `ShellSearchViewModel`).
* Each leaf is `@Serializable` because Nav 3 persists the back stack via
* kotlinx-serialization (process-death restore). Recipes have no tab — they
* land in [RecipeDetail] via the shell-wide search overlay.
*/
sealed interface Screen : NavKey {
sealed interface Home : Screen {
@@ -35,4 +28,12 @@ sealed interface Screen : NavKey {
@Serializable
data object Home : Shopping
}
@Serializable
data class RecipeDetail(val recipeId: String) : Screen
sealed interface MealPlanEditor : Screen {
@Serializable
data class Open(val source: MealPlanEditorSource) : MealPlanEditor
}
}

View File

@@ -0,0 +1,11 @@
package dev.ulfrx.recipe.ui.components.recipe
data class RecipeUi(
val id: String,
val title: String,
val cookingMinutes: Int,
val nutrition: RecipeNutritionUi,
val ingredients: List<RecipeIngredientSlotUi>,
val steps: List<String>,
val allowedSlots: List<MealSlot>,
)

View File

@@ -0,0 +1,158 @@
package dev.ulfrx.recipe.ui.components.sheet
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.composables.core.BottomSheetScope
import com.composables.core.DragIndication
import com.composables.core.ModalBottomSheet
import com.composables.core.ModalBottomSheetState
import com.composables.core.Scrim
import com.composables.core.Sheet
import com.composables.core.SheetDetent
import com.composables.core.rememberModalBottomSheetState
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.sheet_drag_handle_a11y
@Composable
fun <T : NavKey> RecipeBottomSheet(
state: RecipeBottomSheetState<T>,
modifier: Modifier = Modifier,
entries: EntryProviderScope<T>.() -> Unit,
) {
val modalSheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.Hidden, detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded))
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<T>()
val viewModelDecorator = rememberViewModelStoreNavEntryDecorator<T>()
OpenOrCloseSheetBasedOnVisibility(modalSheetState, state.isOpen)
EmitDismissOnUserCancel(modalSheetState, state)
ModalBottomSheet(state = modalSheetState) {
Scrim(
scrimColor = SCRIM_COLOR,
enter = fadeIn(tween(SCRIM_FADE_MILLIS)),
exit = fadeOut(tween(SCRIM_FADE_MILLIS)),
)
Sheet(
modifier = modifier.fillMaxWidth(),
backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
) {
SheetBody {
if (state.backStack.isNotEmpty()) {
NavDisplay(
backStack = state.backStack,
modifier = Modifier.fillMaxSize(),
onBack = { state.pop() },
entryDecorators = listOf(saveableDecorator, viewModelDecorator),
entryProvider = entryProvider(builder = entries),
)
} else {
Box(modifier = Modifier.fillMaxSize())
}
}
}
}
}
@Composable
private fun BottomSheetScope.SheetBody(content: @Composable BoxScope.() -> Unit) {
val backdrop = rememberGlassBackdropState()
val spacing = RecipeTheme.spacing
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(SHEET_HEIGHT_FRACTION)
.background(RecipeTheme.colors.background),
) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize(), content = content)
}
SheetHandle(
modifier = Modifier.align(Alignment.TopCenter).padding(top = spacing.sm),
)
}
}
}
@Composable
private fun OpenOrCloseSheetBasedOnVisibility(
modalSheetState: ModalBottomSheetState,
visible: Boolean,
) {
LaunchedEffect(visible) {
modalSheetState.targetDetent =
if (visible) SheetDetent.FullyExpanded else SheetDetent.Hidden
}
}
@Composable
private fun <T : NavKey> EmitDismissOnUserCancel(
modalSheetState: ModalBottomSheetState,
state: RecipeBottomSheetState<T>,
) {
LaunchedEffect(modalSheetState.isIdle, modalSheetState.currentDetent) {
if (modalSheetState.isIdle && modalSheetState.currentDetent == SheetDetent.Hidden) {
state.dismiss()
}
}
}
@Composable
private fun BottomSheetScope.SheetHandle(modifier: Modifier = Modifier) {
val colors = RecipeTheme.colors
val label = stringResource(Res.string.sheet_drag_handle_a11y)
DragIndication(
modifier =
modifier
.semantics { this.contentDescription = label }
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface.copy(alpha = HandleAlpha))
.width(HandleWidth)
.height(HandleHeight),
)
}
private const val SHEET_HEIGHT_FRACTION = 0.92f
private const val SCRIM_FADE_MILLIS = 250
private const val HandleAlpha = 0.85f
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
private val SHEET_CORNER_RADIUS = 28.dp
private val HandleWidth = 36.dp
private val HandleHeight = 5.dp

View File

@@ -0,0 +1,40 @@
package dev.ulfrx.recipe.ui.components.sheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.NavKey
@Stable
class RecipeBottomSheetState<T : NavKey> {
val backStack: SnapshotStateList<T> = mutableStateListOf()
val isOpen by derivedStateOf { backStack.isNotEmpty() }
fun push(entry: T) {
backStack.add(entry)
}
fun pop() {
if (backStack.isNotEmpty()) {
backStack.removeAt(backStack.lastIndex)
}
}
fun open(entry: T) {
backStack.clear()
backStack.add(entry)
}
fun dismiss() {
backStack.clear()
}
}
@Composable
fun <T : NavKey> rememberRecipeBottomSheetState(): RecipeBottomSheetState<T> =
remember { RecipeBottomSheetState() }

View File

@@ -50,12 +50,6 @@ import recipe.composeapp.generated.resources.nutrition_label
import recipe.composeapp.generated.resources.recipe_detail_servings_decrement_a11y
import recipe.composeapp.generated.resources.recipe_detail_servings_increment_a11y
/**
* Scrollable body of the meal-plan editor. Stays a pure stateless renderer —
* top floating actions (back, confirm) and the sheet handle live one level up
* inside [RecipeDetailSheet] so both detail and editor content composables can
* share the same chrome.
*/
@Composable
internal fun MealPlanEditorContent(
editing: MealPlanEditorState.Editing,
@@ -104,10 +98,8 @@ internal fun MealPlanEditorContent(
.verticalScroll(scrollState, enabled = !addPanelOpen),
) {
Spacer(Modifier.height(topChromeInset))
// Title sits in a row whose vertical bounds match the floating chrome
// (back/add buttons) so at scroll=0 it reads as the centre of the
// toolbar. Horizontal inset clears the chrome buttons — they are
// square pills of [topChromeHeight] anchored at spacing.lg.
// Aligns the title row with the floating back/confirm chrome at
// scroll=0: same height, padded inside the chrome's circle pills.
Box(
modifier =
Modifier

View File

@@ -0,0 +1,125 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.meal_plan_editor_back_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_confirm_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_not_found
@Composable
internal fun MealPlanEditorScreen(
viewModel: MealPlanEditorViewModel,
onBack: () -> Unit,
onConfirm: (PlannedMealUi) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val spacing = RecipeTheme.spacing
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is MealPlanEditorState.Editing -> {
MealPlanEditorContent(
editing = s,
catalog = sampleAddableIngredients,
topChromeInset = TopActionsTopInset,
topChromeHeight = TopPillHeight,
onSelectDate = viewModel::selectDate,
onSetCalendarExpanded = viewModel::setCalendarExpanded,
onSelectSlot = viewModel::selectSlot,
onSetServings = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
onRemoveRecipeIngredient = viewModel::removeRecipeIngredient,
onRemoveAddedIngredient = viewModel::removeAddedIngredient,
onRestoreRemoved = viewModel::restoreRemovedIngredients,
onAddIngredient = viewModel::addIngredient,
)
EditorChromeRow(
showConfirm = true,
onBack = onBack,
onConfirm = { viewModel.confirm()?.let(onConfirm) },
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
)
}
MealPlanEditorState.NotFound -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_not_found),
style = RecipeTheme.typography.body,
)
}
EditorChromeRow(
showConfirm = false,
onBack = onBack,
onConfirm = {},
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
)
}
}
}
}
@Composable
private fun EditorChromeRow(
showConfirm: Boolean,
onBack: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CircleGlassButton(
onClick = onBack,
icon = Lucide.ArrowLeft,
contentDescription = stringResource(Res.string.meal_plan_editor_back_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
)
if (showConfirm) {
CircleGlassButton(
onClick = onConfirm,
icon = Lucide.Plus,
contentDescription = stringResource(Res.string.meal_plan_editor_confirm_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
)
}
}
}
private val TopPillHeight = 44.dp
private val TopActionIconSize = 18.dp
private val TopActionsTopInset = 28.dp

View File

@@ -1,17 +1,18 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import kotlinx.datetime.LocalDate
internal const val MIN_PLAN_SERVINGS = 1
internal const val MAX_PLAN_SERVINGS = 12
sealed interface MealPlanEditorState {
data object Hidden : MealPlanEditorState
data object NotFound : MealPlanEditorState
data class Editing(
val recipe: RecipeDetailUi,
val id: String,
val recipe: RecipeUi,
val selectedDate: LocalDate,
val selectedSlot: MealSlot,
val calendarExpanded: Boolean = false,

View File

@@ -3,11 +3,6 @@ package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import kotlinx.datetime.LocalDate
/**
* Ingredient appended to a recipe inside the editor (not part of the recipe's
* original ingredient list). Removed by id — never deduped into the recipe's
* exclusion set.
*/
data class AddedIngredientUi(
val ingredientId: String,
val name: String,
@@ -15,7 +10,6 @@ data class AddedIngredientUi(
val unit: String,
)
/** Catalog entry shown in the "Dodaj składnik" search panel. */
data class AddableIngredientUi(
val ingredientId: String,
val name: String,
@@ -23,12 +17,8 @@ data class AddableIngredientUi(
val defaultUnit: String,
)
/**
* Payload emitted by [MealPlanEditorViewModel.confirm] when the user adds the
* meal to the plan. Persistence to `planStore` and the sync engine lands in
* Phase 6+; the editor itself produces this value-type only.
*/
data class PlannedMealUi(
val id: String,
val recipeId: String,
val date: LocalDate,
val slot: MealSlot,

View File

@@ -1,45 +1,31 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import dev.ulfrx.recipe.ui.components.recipe.options
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class MealPlanEditorViewModel : ViewModel() {
private val _state = MutableStateFlow<MealPlanEditorState>(MealPlanEditorState.Hidden)
class MealPlanEditorViewModel(
source: MealPlanEditorSource,
recipeProvider: (String) -> RecipeUi?,
plannedMealProvider: (String) -> PlannedMealUi?,
) : ViewModel() {
private val _state = MutableStateFlow(loadInitial(source, recipeProvider, plannedMealProvider))
val state: StateFlow<MealPlanEditorState> = _state.asStateFlow()
fun open(
recipe: RecipeDetailUi,
initialSubstitutions: Map<String, String> = emptyMap(),
initialServings: Int = MIN_PLAN_SERVINGS,
initialDate: LocalDate = todayInSystemTz(),
) {
val slot = recipe.allowedSlots.firstOrNull() ?: MealSlot.entries.first()
_state.value =
MealPlanEditorState.Editing(
recipe = recipe,
selectedDate = initialDate,
selectedSlot = slot,
servings = initialServings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
substitutions = initialSubstitutions.filterValid(recipe),
)
}
fun close() {
_state.value = MealPlanEditorState.Hidden
}
fun confirm(): PlannedMealUi? {
val editing = _state.value as? MealPlanEditorState.Editing ?: return null
_state.value = MealPlanEditorState.Hidden
return PlannedMealUi(
id = editing.id,
recipeId = editing.recipe.id,
date = editing.selectedDate,
slot = editing.selectedSlot,
@@ -50,11 +36,9 @@ class MealPlanEditorViewModel : ViewModel() {
)
}
fun selectDate(date: LocalDate) =
updateEditing { it.copy(selectedDate = date) }
fun selectDate(date: LocalDate) = updateEditing { it.copy(selectedDate = date) }
fun setCalendarExpanded(expanded: Boolean) =
updateEditing { it.copy(calendarExpanded = expanded) }
fun setCalendarExpanded(expanded: Boolean) = updateEditing { it.copy(calendarExpanded = expanded) }
fun selectSlot(slot: MealSlot) =
updateEditing {
@@ -104,12 +88,6 @@ class MealPlanEditorViewModel : ViewModel() {
}
}
private fun Map<String, String>.filterValid(recipe: RecipeDetailUi): Map<String, String> =
filter { (slotId, optionId) ->
val slot = recipe.ingredients.firstOrNull { it.id == slotId }
slot != null && slot.options.any { it.id == optionId }
}
private fun AddableIngredientUi.toAdded() =
AddedIngredientUi(
ingredientId = ingredientId,
@@ -118,3 +96,52 @@ class MealPlanEditorViewModel : ViewModel() {
unit = defaultUnit,
)
}
@OptIn(ExperimentalUuidApi::class)
private fun loadInitial(
source: MealPlanEditorSource,
recipeProvider: (String) -> RecipeUi?,
plannedMealProvider: (String) -> PlannedMealUi?,
): MealPlanEditorState =
when (source) {
is MealPlanEditorSource.NewFromRecipe -> {
val recipe = recipeProvider(source.recipeId)
if (recipe == null) {
MealPlanEditorState.NotFound
} else {
MealPlanEditorState.Editing(
id = "plan_${Uuid.random()}",
recipe = recipe,
selectedDate = todayInSystemTz(),
selectedSlot = recipe.allowedSlots.firstOrNull() ?: MealSlot.entries.first(),
servings = source.initialServings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
substitutions = source.initialSubstitutions.filterValid(recipe),
)
}
}
is MealPlanEditorSource.EditExistingPlan -> {
val planned = plannedMealProvider(source.plannedMealId)
val recipe = planned?.let { recipeProvider(it.recipeId) }
if (planned == null || recipe == null) {
MealPlanEditorState.NotFound
} else {
MealPlanEditorState.Editing(
id = planned.id,
recipe = recipe,
selectedDate = planned.date,
selectedSlot = planned.slot,
servings = planned.servings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
substitutions = planned.substitutions.filterValid(recipe),
excludedIngredients = planned.excludedIngredients,
addedIngredients = planned.addedIngredients,
)
}
}
}
private fun Map<String, String>.filterValid(recipe: RecipeUi): Map<String, String> =
filter { (slotId, optionId) ->
val slot = recipe.ingredients.firstOrNull { it.id == slotId }
slot != null && slot.options.any { it.id == optionId }
}

View File

@@ -0,0 +1,189 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.ulfrx.recipe.ui.components.recipe.IngredientCard
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeServingsStepper
import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
import dev.ulfrx.recipe.ui.components.section.Section
import dev.ulfrx.recipe.ui.components.section.SectionTitle
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_label
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
@Composable
internal fun RecipeDetailContent(
ready: RecipeDetailState.Ready,
onPlanClick: () -> Unit,
onServingsChange: (Int) -> Unit,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
modifier: Modifier = Modifier,
) {
val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState()
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val detail = ready.recipe
val servings = ready.servings
Column(modifier = modifier.fillMaxSize().verticalScroll(scrollState)) {
RecipeDetailHero(
title = detail.title,
cookingMinutes = detail.cookingMinutes,
onPlanClick = onPlanClick,
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
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))
}
}
}
@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))
RecipeServingsStepper(
servings = servings,
servingsRange = MIN_RECIPE_SERVINGS..MAX_RECIPE_SERVINGS,
decrementContentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
incrementContentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
onServingsChange = onServingsChange,
)
}
}
@Composable
private fun IngredientsSection(
ingredients: List<RecipeIngredientSlotUi>,
servings: Int,
substitutions: Map<String, String>,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
) {
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
IngredientCard {
ingredients.forEachIndexed { index, slot ->
if (index > 0) IngredientDivider()
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<String>) {
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 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.Bold,
fontSize = StepNumberTextSize,
),
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),
)
}
}
private val StepNumberWidth = 20.dp
private val StepNumberTextSize = 11.sp
private val StepTextSize = 13.sp
private val StepLineHeight = 19.sp

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.recipe_detail_not_found
@Composable
internal fun RecipeDetailScreen(
viewModel: RecipeDetailViewModel,
onPlan: (RecipeDetailState.Ready) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is RecipeDetailState.Ready ->
RecipeDetailContent(
ready = s,
onPlanClick = { onPlan(s) },
onServingsChange = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
)
RecipeDetailState.NotFound ->
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
BasicText(
text = stringResource(Res.string.recipe_detail_not_found),
style = RecipeTheme.typography.body,
)
}
}
}

View File

@@ -1,456 +0,0 @@
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.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.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.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.Color
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.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.recipe.IngredientCard
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
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.section.Section
import dev.ulfrx.recipe.ui.components.section.SectionTitle
import dev.ulfrx.recipe.ui.components.recipe.RecipeServingsStepper
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
import dev.ulfrx.recipe.ui.screens.mealplaneditor.AddableIngredientUi
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorContent
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorState
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.mealplaneditor.PlannedMealUi
import dev.ulfrx.recipe.ui.screens.mealplaneditor.sampleAddableIngredients
import kotlinx.datetime.LocalDate
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.meal_plan_editor_back_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_confirm_a11y
import recipe.composeapp.generated.resources.nutrition_label
import recipe.composeapp.generated.resources.recipe_detail_handle_a11y
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
@Composable
fun RecipeDetailSheet(
detailViewModel: RecipeDetailViewModel,
editorViewModel: MealPlanEditorViewModel,
onPlanConfirmed: (PlannedMealUi) -> Unit,
) {
val detailState by detailViewModel.state.collectAsStateWithLifecycle()
val editorState by editorViewModel.state.collectAsStateWithLifecycle()
val ready = detailState as? RecipeDetailState.Ready
val editing = editorState as? MealPlanEditorState.Editing
val anyOpen = ready != null || editing != null
val sheetState =
rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
)
LaunchedEffect(anyOpen) {
sheetState.targetDetent = if (anyOpen) SheetDetent.FullyExpanded else SheetDetent.Hidden
}
// Only caller of the dismiss path: a drag that settles the sheet at Hidden
// while either VM still holds state. 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 && anyOpen) {
editorViewModel.close()
detailViewModel.dismiss()
}
}
ModalBottomSheet(state = sheetState) {
Scrim(
scrimColor = SCRIM_COLOR,
enter = fadeIn(tween(SCRIM_FADE_MILLIS)),
exit = fadeOut(tween(SCRIM_FADE_MILLIS)),
)
Sheet(
modifier = Modifier.fillMaxWidth(),
backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
) {
SheetBody(
editing = editing,
ready = ready,
onOpenEditor = {
val current = ready ?: return@SheetBody
editorViewModel.open(
recipe = current.recipe,
initialSubstitutions = current.substitutions,
initialServings = current.servings,
)
},
onCloseEditor = editorViewModel::close,
onConfirmEditor = {
val planned = editorViewModel.confirm() ?: return@SheetBody
onPlanConfirmed(planned)
sheetState.targetDetent = SheetDetent.Hidden
},
detailActions =
RecipeDetailActions(
onServingsChange = detailViewModel::setServings,
onSelectSubstitution = detailViewModel::selectSubstitution,
),
editorActions =
EditorActions(
onSelectDate = editorViewModel::selectDate,
onSetCalendarExpanded = editorViewModel::setCalendarExpanded,
onSelectSlot = editorViewModel::selectSlot,
onSetServings = editorViewModel::setServings,
onSelectSubstitution = editorViewModel::selectSubstitution,
onRemoveRecipeIngredient = editorViewModel::removeRecipeIngredient,
onRemoveAddedIngredient = editorViewModel::removeAddedIngredient,
onRestoreRemoved = editorViewModel::restoreRemovedIngredients,
onAddIngredient = editorViewModel::addIngredient,
),
)
}
}
}
@Composable
private fun BottomSheetScope.SheetBody(
editing: MealPlanEditorState.Editing?,
ready: RecipeDetailState.Ready?,
onOpenEditor: () -> Unit,
onCloseEditor: () -> Unit,
onConfirmEditor: () -> Unit,
detailActions: RecipeDetailActions,
editorActions: EditorActions,
) {
val backdrop = rememberGlassBackdropState()
val handleLabel = stringResource(Res.string.recipe_detail_handle_a11y)
val spacing = RecipeTheme.spacing
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
when {
editing != null ->
MealPlanEditorContent(
editing = editing,
catalog = sampleAddableIngredients,
topChromeInset = TopActionsTopInset,
topChromeHeight = TopPillHeight,
onSelectDate = editorActions.onSelectDate,
onSetCalendarExpanded = editorActions.onSetCalendarExpanded,
onSelectSlot = editorActions.onSelectSlot,
onSetServings = editorActions.onSetServings,
onSelectSubstitution = editorActions.onSelectSubstitution,
onRemoveRecipeIngredient = editorActions.onRemoveRecipeIngredient,
onRemoveAddedIngredient = editorActions.onRemoveAddedIngredient,
onRestoreRemoved = editorActions.onRestoreRemoved,
onAddIngredient = editorActions.onAddIngredient,
)
ready != null ->
RecipeDetailBody(
ready = ready,
onPlanClick = onOpenEditor,
onServingsChange = detailActions.onServingsChange,
onSelectSubstitution = detailActions.onSelectSubstitution,
)
}
}
SheetHandle(
contentDescription = handleLabel,
modifier = Modifier.align(Alignment.TopCenter).padding(top = spacing.sm),
)
if (editing != null) {
EditorTopActions(
onBack = onCloseEditor,
onConfirm = onConfirmEditor,
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
)
}
}
}
}
@Composable
private fun EditorTopActions(
onBack: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CircleGlassButton(
onClick = onBack,
icon = Lucide.ArrowLeft,
contentDescription = stringResource(Res.string.meal_plan_editor_back_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
)
CircleGlassButton(
onClick = onConfirm,
icon = Lucide.Plus,
contentDescription = stringResource(Res.string.meal_plan_editor_confirm_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
)
}
}
@Composable
private fun BottomSheetScope.SheetHandle(
contentDescription: String,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
DragIndication(
modifier =
modifier
.semantics { this.contentDescription = contentDescription }
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface.copy(alpha = HandleAlpha))
.width(HandleWidth)
.height(HandleHeight),
)
}
@Composable
private fun RecipeDetailBody(
ready: RecipeDetailState.Ready,
onPlanClick: () -> Unit,
onServingsChange: (Int) -> Unit,
onSelectSubstitution: (String, String) -> Unit,
) {
val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState()
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val detail = ready.recipe
val servings = ready.servings
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
RecipeDetailHero(
title = detail.title,
cookingMinutes = detail.cookingMinutes,
onPlanClick = onPlanClick,
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
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))
}
}
}
@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))
RecipeServingsStepper(
servings = servings,
servingsRange = MIN_RECIPE_SERVINGS..MAX_RECIPE_SERVINGS,
decrementContentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
incrementContentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
onServingsChange = onServingsChange,
)
}
}
@Composable
private fun IngredientsSection(
ingredients: List<RecipeIngredientSlotUi>,
servings: Int,
substitutions: Map<String, String>,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
) {
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
IngredientCard {
ingredients.forEachIndexed { index, slot ->
if (index > 0) IngredientDivider()
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<String>) {
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 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.Bold,
fontSize = StepNumberTextSize,
),
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),
)
}
}
private class RecipeDetailActions(
val onServingsChange: (Int) -> Unit,
val onSelectSubstitution: (String, String) -> Unit,
)
private class EditorActions(
val onSelectDate: (LocalDate) -> Unit,
val onSetCalendarExpanded: (Boolean) -> Unit,
val onSelectSlot: (MealSlot) -> Unit,
val onSetServings: (Int) -> Unit,
val onSelectSubstitution: (String, String) -> Unit,
val onRemoveRecipeIngredient: (String) -> Unit,
val onRemoveAddedIngredient: (String) -> Unit,
val onRestoreRemoved: () -> Unit,
val onAddIngredient: (AddableIngredientUi) -> Unit,
)
private const val SHEET_HEIGHT_FRACTION = 0.92f
private const val SCRIM_FADE_MILLIS = 250
private const val HandleAlpha = 0.85f
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
private val SHEET_CORNER_RADIUS = 28.dp
private val HandleWidth = 36.dp
private val HandleHeight = 5.dp
private val StepNumberWidth = 20.dp
private val TopPillHeight = 44.dp
private val TopActionIconSize = 18.dp
private val TopActionsTopInset = 28.dp
private val StepNumberTextSize = 11.sp
private val StepTextSize = 13.sp
private val StepLineHeight = 19.sp

View File

@@ -1,13 +1,15 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
internal const val MIN_RECIPE_SERVINGS = 1
internal const val MAX_RECIPE_SERVINGS = 12
sealed interface RecipeDetailState {
data object Hidden : RecipeDetailState
data object NotFound : RecipeDetailState
data class Ready(
val recipe: RecipeDetailUi,
val recipe: RecipeUi,
val servings: Int = MIN_RECIPE_SERVINGS,
val substitutions: Map<String, String> = emptyMap(),
) : RecipeDetailState

View File

@@ -1,15 +0,0 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
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<RecipeIngredientSlotUi>,
val steps: List<String>,
val allowedSlots: List<MealSlot>,
)

View File

@@ -7,18 +7,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class RecipeDetailViewModel : ViewModel() {
private val _state = MutableStateFlow<RecipeDetailState>(RecipeDetailState.Hidden)
class RecipeDetailViewModel(recipeId: String) : ViewModel() {
private val _state = MutableStateFlow(sampleRecipe(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.NotFound)
val state: StateFlow<RecipeDetailState> = _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) {

View File

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
@@ -9,9 +10,9 @@ private val LunchOrDinner = listOf(MealSlot.Lunch, MealSlot.Dinner)
private val BreakfastOrSnack = listOf(MealSlot.Breakfast, MealSlot.Snack)
private val LightMeal = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper)
internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
internal val sampleRecipes: Map<String, RecipeUi> =
listOf(
RecipeDetailUi(
RecipeUi(
id = "rcp_nalesniki",
title = "Naleśniki z twarogiem",
cookingMinutes = 25,
@@ -40,7 +41,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami",
cookingMinutes = 10,
@@ -81,7 +82,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Polej miodem i podawaj.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_spaghetti",
title = "Spaghetti bolognese",
cookingMinutes = 40,
@@ -110,7 +111,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Wymieszaj sos z odsączonym makaronem i podawaj.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_pierogi",
title = "Pierogi ruskie",
cookingMinutes = 90,
@@ -134,7 +135,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Podawaj okraszone masłem i podsmażoną cebulą.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem",
cookingMinutes = 5,
@@ -156,7 +157,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Ułóż plastry jajka i posyp szczypiorkiem.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_schabowy",
title = "Schabowy z ziemniakami",
cookingMinutes = 60,
@@ -180,7 +181,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Podawaj z ziemniakami i ulubioną surówką.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_salatka_grecka",
title = "Sałatka grecka",
cookingMinutes = 15,
@@ -203,7 +204,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem",
cookingMinutes = 35,
@@ -225,7 +226,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati",
cookingMinutes = 45,
@@ -249,7 +250,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Podawaj z ryżem basmati.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem",
cookingMinutes = 8,
@@ -269,7 +270,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Posyp posiekanym szczypiorkiem i podawaj.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_risotto",
title = "Risotto z grzybami leśnymi",
cookingMinutes = 35,
@@ -292,7 +293,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami",
cookingMinutes = 20,
@@ -315,7 +316,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe",
cookingMinutes = 5,
@@ -341,7 +342,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Przelej do szklanki i podawaj od razu.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_losos",
title = "Łosoś pieczony z brokułami",
cookingMinutes = 30,
@@ -363,7 +364,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Piecz łososia i brokuły na blasze ok. 1518 minut.",
),
),
RecipeDetailUi(
RecipeUi(
id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami",
cookingMinutes = 55,
@@ -389,7 +390,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
),
).associateBy { it.id }
internal fun sampleRecipeDetail(id: String): RecipeDetailUi? = sampleRecipeDetails[id]
internal fun sampleRecipe(id: String): RecipeUi? = sampleRecipes[id]
private fun slot(
name: String,

View File

@@ -0,0 +1,50 @@
package dev.ulfrx.recipe.ui.screens.recipesheet
import androidx.compose.runtime.Composable
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.navigation.Screen
import dev.ulfrx.recipe.ui.components.sheet.RecipeBottomSheet
import dev.ulfrx.recipe.ui.components.sheet.RecipeBottomSheetState
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorScreen
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailScreen
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun RecipeSheet(state: RecipeBottomSheetState<Screen>) {
RecipeBottomSheet(state = state) {
entry<Screen.RecipeDetail> { key ->
val vm: RecipeDetailViewModel = koinViewModel { parametersOf(key.recipeId) }
RecipeDetailScreen(
viewModel = vm,
onPlan = { ready ->
state.push(
Screen.MealPlanEditor.Open(
MealPlanEditorSource.NewFromRecipe(
recipeId = ready.recipe.id,
initialServings = ready.servings,
initialSubstitutions = ready.substitutions,
),
),
)
},
)
}
entry<Screen.MealPlanEditor.Open> { key ->
val vm: MealPlanEditorViewModel = koinViewModel { parametersOf(key.source) }
MealPlanEditorScreen(
viewModel = vm,
onBack = state::pop,
onConfirm = { _ ->
// TODO Phase 6: persist via PlannedMealsRepository
state.dismiss()
},
)
}
}
}

View File

@@ -14,15 +14,15 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.navigation.Screen
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.mealplaneditor.PlannedMealUi
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.components.sheet.rememberRecipeBottomSheetState
import dev.ulfrx.recipe.ui.screens.recipesheet.RecipeSheet
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle
import recipe.composeapp.generated.resources.search_screen_empty_results_title
@@ -30,14 +30,12 @@ import recipe.composeapp.generated.resources.search_screen_empty_results_title
@Composable
fun SearchScreen(
viewModel: ShellSearchViewModel,
catalogViewModel: RecipeCatalogViewModel,
detailViewModel: RecipeDetailViewModel,
editorViewModel: MealPlanEditorViewModel,
catalogGridState: LazyGridState,
onPlanConfirmed: (PlannedMealUi) -> Unit = {},
) {
val catalogViewModel: RecipeCatalogViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
val bottomSheetState = rememberRecipeBottomSheetState<Screen>()
Box(
modifier =
@@ -62,16 +60,12 @@ fun SearchScreen(
} else {
RecipeCatalogGrid(
state = catalogState,
onRecipeClick = detailViewModel::open,
onRecipeClick = { bottomSheetState.open(Screen.RecipeDetail(it)) },
gridState = catalogGridState,
modifier = Modifier.fillMaxSize(),
)
}
RecipeDetailSheet(
detailViewModel = detailViewModel,
editorViewModel = editorViewModel,
onPlanConfirmed = onPlanConfirmed,
)
RecipeSheet(state = bottomSheetState)
}
}

View File

@@ -25,11 +25,8 @@ import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
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.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel
@@ -38,9 +35,6 @@ import org.koin.compose.viewmodel.koinViewModel
fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel()
val catalogVm: RecipeCatalogViewModel = koinViewModel()
val detailVm: RecipeDetailViewModel = koinViewModel()
val editorVm: MealPlanEditorViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState()
@@ -72,9 +66,6 @@ fun AppShell(modifier: Modifier = Modifier) {
if (searchOpen) {
SearchScreen(
viewModel = searchVm,
catalogViewModel = catalogVm,
detailViewModel = detailVm,
editorViewModel = editorVm,
catalogGridState = catalogGridState,
)
} else {

View File

@@ -1,245 +0,0 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class MealPlanEditorViewModelTest {
@Test
fun opensWithDefaultsDerivedFromRecipe() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe(allowedSlots = listOf(MealSlot.Lunch, MealSlot.Dinner)))
val state = viewModel.editing()
assertEquals(MealSlot.Lunch, state.selectedSlot)
assertEquals(MIN_PLAN_SERVINGS, state.servings)
assertTrue(state.substitutions.isEmpty())
assertTrue(state.excludedIngredients.isEmpty())
assertTrue(state.addedIngredients.isEmpty())
}
@Test
fun initialSubstitutionsAreSanitizedAgainstRecipe() {
val viewModel = MealPlanEditorViewModel()
val recipe = recipe(allowedSlots = listOf(MealSlot.Breakfast))
val validSlot = recipe.ingredients.first { it.alternatives.isNotEmpty() }
val validOption = validSlot.alternatives.first()
viewModel.open(
recipe = recipe,
initialSubstitutions = mapOf(
validSlot.id to validOption.id,
"unknown-slot" to "anything",
validSlot.id + "-x" to validOption.id,
),
)
val state = viewModel.editing()
assertEquals(mapOf(validSlot.id to validOption.id), state.substitutions)
}
@Test
fun closeResetsStateToHidden() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
viewModel.close()
assertEquals(MealPlanEditorState.Hidden, viewModel.state.value)
}
@Test
fun confirmReturnsPayloadAndClosesEditor() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe(allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Lunch)))
viewModel.setServings(4)
viewModel.selectSlot(MealSlot.Lunch)
viewModel.selectDate(LocalDate(2026, 7, 1))
val payload = viewModel.confirm()
assertNotNull(payload)
assertEquals(4, payload.servings)
assertEquals(MealSlot.Lunch, payload.slot)
assertEquals(LocalDate(2026, 7, 1), payload.date)
assertEquals(MealPlanEditorState.Hidden, viewModel.state.value)
}
@Test
fun confirmReturnsNullWhenHidden() {
val viewModel = MealPlanEditorViewModel()
assertNull(viewModel.confirm())
}
@Test
fun servingsAreClampedToSupportedRange() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
viewModel.setServings(0)
assertEquals(MIN_PLAN_SERVINGS, viewModel.editing().servings)
viewModel.setServings(MAX_PLAN_SERVINGS + 5)
assertEquals(MAX_PLAN_SERVINGS, viewModel.editing().servings)
}
@Test
fun selectSlotIgnoresValuesOutsideAllowedSet() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe(allowedSlots = listOf(MealSlot.Breakfast)))
viewModel.selectSlot(MealSlot.Dinner)
assertEquals(MealSlot.Breakfast, viewModel.editing().selectedSlot)
}
@Test
fun substitutionTogglesByPickingDefaultAgain() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val slot = viewModel.editing().recipe.ingredients.first { it.alternatives.isNotEmpty() }
val alt = slot.alternatives.first()
viewModel.selectSubstitution(slot.id, alt.id)
assertEquals(alt.id, viewModel.editing().substitutions[slot.id])
viewModel.selectSubstitution(slot.id, slot.default.id)
assertFalse(slot.id in viewModel.editing().substitutions)
}
@Test
fun removeAndRestoreCycleClearsExclusions() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val slot = viewModel.editing().recipe.ingredients.first()
viewModel.removeRecipeIngredient(slot.id)
assertTrue(slot.id in viewModel.editing().excludedIngredients)
viewModel.restoreRemovedIngredients()
assertTrue(viewModel.editing().excludedIngredients.isEmpty())
}
@Test
fun addingIngredientAppendsToEditingList() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val candidate = addable("ing_test", "Test składnik")
viewModel.addIngredient(candidate)
val state = viewModel.editing()
assertEquals(1, state.addedIngredients.size)
assertEquals(candidate.ingredientId, state.addedIngredients.first().ingredientId)
}
@Test
fun addingDuplicateIngredientIsIgnored() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val candidate = addable("ing_test", "Test składnik")
viewModel.addIngredient(candidate)
viewModel.addIngredient(candidate)
assertEquals(1, viewModel.editing().addedIngredients.size)
}
@Test
fun removeAddedDropsByIngredientId() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
viewModel.addIngredient(addable("ing_a", "A"))
viewModel.addIngredient(addable("ing_b", "B"))
viewModel.removeAddedIngredient("ing_a")
val remaining = viewModel.editing().addedIngredients.map { it.ingredientId }
assertEquals(listOf("ing_b"), remaining)
}
private fun MealPlanEditorViewModel.editing(): MealPlanEditorState.Editing {
val state = state.value
assertTrue(state is MealPlanEditorState.Editing)
return state
}
private fun recipe(
allowedSlots: List<MealSlot> = listOf(MealSlot.Breakfast, MealSlot.Snack),
): RecipeDetailUi =
RecipeDetailUi(
id = "test_recipe",
title = "Test recipe",
cookingMinutes = 15,
nutrition = RecipeNutritionUi(kcal = 300, protein = 10, fat = 8, carbs = 40),
allowedSlots = allowedSlots,
steps = listOf("Krok 1.", "Krok 2."),
ingredients =
listOf(
slot(
id = "slot_main",
name = "Płatki",
amount = 60.0,
unit = "g",
alternatives = listOf("Płatki górskie" to 60.0, "Płatki jaglane" to 60.0),
),
slot(
id = "slot_fruit",
name = "Borówki",
amount = 40.0,
unit = "g",
alternatives = listOf("Truskawki" to 50.0),
),
slot(id = "slot_milk", name = "Mleko", amount = 200.0, unit = "ml"),
),
)
private fun slot(
id: String,
name: String,
amount: Double,
unit: String,
alternatives: List<Pair<String, Double>> = emptyList(),
): RecipeIngredientSlotUi =
RecipeIngredientSlotUi(
default =
RecipeIngredientOptionUi(
id = "$id:default",
name = name,
amount = amount,
unit = unit,
),
alternatives =
alternatives.map { (altName, altAmount) ->
RecipeIngredientOptionUi(
id = "$id:alt:$altName",
name = altName,
amount = altAmount,
unit = unit,
)
},
id = id,
)
private fun addable(
id: String,
name: String,
): AddableIngredientUi =
AddableIngredientUi(
ingredientId = id,
name = name,
defaultAmount = 10.0,
defaultUnit = "g",
)
}

View File

@@ -1,81 +0,0 @@
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
}
}

View File

@@ -92,6 +92,7 @@ lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref =
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }