Reorganise bottom sheet, recipe detail and meal plan editor
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. 15–18 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,
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user