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.multiplatform.settings)
|
||||||
implementation(libs.lokksmith.compose)
|
implementation(libs.lokksmith.compose)
|
||||||
implementation(libs.navigation3.ui)
|
implementation(libs.navigation3.ui)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.compose.unstyled)
|
implementation(libs.compose.unstyled)
|
||||||
implementation(libs.compose.icons.lucide)
|
implementation(libs.compose.icons.lucide)
|
||||||
implementation(libs.liquid)
|
implementation(libs.liquid)
|
||||||
|
|||||||
@@ -48,7 +48,9 @@
|
|||||||
<string name="recipe_detail_step_number_format">%1$d.</string>
|
<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_decrement_a11y">Zmniejsz liczbę porcji</string>
|
||||||
<string name="recipe_detail_servings_increment_a11y">Zwiększ 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) -->
|
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
||||||
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package dev.ulfrx.recipe.di
|
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.home.HomeViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
|
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
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.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.plugin.module.dsl.viewModel
|
import org.koin.plugin.module.dsl.viewModel
|
||||||
|
|
||||||
@@ -19,6 +22,16 @@ val shellModule =
|
|||||||
viewModel<ShoppingViewModel>()
|
viewModel<ShoppingViewModel>()
|
||||||
viewModel<ShellSearchViewModel>()
|
viewModel<ShellSearchViewModel>()
|
||||||
viewModel<RecipeCatalogViewModel>()
|
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
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
|
* Each leaf is `@Serializable` because Nav 3 persists the back stack via
|
||||||
* back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration).
|
* kotlinx-serialization (process-death restore). Recipes have no tab — they
|
||||||
*
|
* land in [RecipeDetail] via the shell-wide search overlay.
|
||||||
* 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`).
|
|
||||||
*/
|
*/
|
||||||
sealed interface Screen : NavKey {
|
sealed interface Screen : NavKey {
|
||||||
sealed interface Home : Screen {
|
sealed interface Home : Screen {
|
||||||
@@ -35,4 +28,12 @@ sealed interface Screen : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Home : Shopping
|
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_decrement_a11y
|
||||||
import recipe.composeapp.generated.resources.recipe_detail_servings_increment_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
|
@Composable
|
||||||
internal fun MealPlanEditorContent(
|
internal fun MealPlanEditorContent(
|
||||||
editing: MealPlanEditorState.Editing,
|
editing: MealPlanEditorState.Editing,
|
||||||
@@ -104,10 +98,8 @@ internal fun MealPlanEditorContent(
|
|||||||
.verticalScroll(scrollState, enabled = !addPanelOpen),
|
.verticalScroll(scrollState, enabled = !addPanelOpen),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(topChromeInset))
|
Spacer(Modifier.height(topChromeInset))
|
||||||
// Title sits in a row whose vertical bounds match the floating chrome
|
// Aligns the title row with the floating back/confirm chrome at
|
||||||
// (back/add buttons) so at scroll=0 it reads as the centre of the
|
// scroll=0: same height, padded inside the chrome's circle pills.
|
||||||
// toolbar. Horizontal inset clears the chrome buttons — they are
|
|
||||||
// square pills of [topChromeHeight] anchored at spacing.lg.
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
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
|
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||||
|
|
||||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
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
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
internal const val MIN_PLAN_SERVINGS = 1
|
internal const val MIN_PLAN_SERVINGS = 1
|
||||||
internal const val MAX_PLAN_SERVINGS = 12
|
internal const val MAX_PLAN_SERVINGS = 12
|
||||||
|
|
||||||
sealed interface MealPlanEditorState {
|
sealed interface MealPlanEditorState {
|
||||||
data object Hidden : MealPlanEditorState
|
data object NotFound : MealPlanEditorState
|
||||||
|
|
||||||
data class Editing(
|
data class Editing(
|
||||||
val recipe: RecipeDetailUi,
|
val id: String,
|
||||||
|
val recipe: RecipeUi,
|
||||||
val selectedDate: LocalDate,
|
val selectedDate: LocalDate,
|
||||||
val selectedSlot: MealSlot,
|
val selectedSlot: MealSlot,
|
||||||
val calendarExpanded: Boolean = false,
|
val calendarExpanded: Boolean = false,
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
|||||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
||||||
import kotlinx.datetime.LocalDate
|
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(
|
data class AddedIngredientUi(
|
||||||
val ingredientId: String,
|
val ingredientId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -15,7 +10,6 @@ data class AddedIngredientUi(
|
|||||||
val unit: String,
|
val unit: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Catalog entry shown in the "Dodaj składnik" search panel. */
|
|
||||||
data class AddableIngredientUi(
|
data class AddableIngredientUi(
|
||||||
val ingredientId: String,
|
val ingredientId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -23,12 +17,8 @@ data class AddableIngredientUi(
|
|||||||
val defaultUnit: String,
|
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(
|
data class PlannedMealUi(
|
||||||
|
val id: String,
|
||||||
val recipeId: String,
|
val recipeId: String,
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
val slot: MealSlot,
|
val slot: MealSlot,
|
||||||
|
|||||||
@@ -1,45 +1,31 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
|
||||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
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.components.recipe.options
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
class MealPlanEditorViewModel : ViewModel() {
|
class MealPlanEditorViewModel(
|
||||||
private val _state = MutableStateFlow<MealPlanEditorState>(MealPlanEditorState.Hidden)
|
source: MealPlanEditorSource,
|
||||||
|
recipeProvider: (String) -> RecipeUi?,
|
||||||
|
plannedMealProvider: (String) -> PlannedMealUi?,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(loadInitial(source, recipeProvider, plannedMealProvider))
|
||||||
val state: StateFlow<MealPlanEditorState> = _state.asStateFlow()
|
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? {
|
fun confirm(): PlannedMealUi? {
|
||||||
val editing = _state.value as? MealPlanEditorState.Editing ?: return null
|
val editing = _state.value as? MealPlanEditorState.Editing ?: return null
|
||||||
_state.value = MealPlanEditorState.Hidden
|
|
||||||
return PlannedMealUi(
|
return PlannedMealUi(
|
||||||
|
id = editing.id,
|
||||||
recipeId = editing.recipe.id,
|
recipeId = editing.recipe.id,
|
||||||
date = editing.selectedDate,
|
date = editing.selectedDate,
|
||||||
slot = editing.selectedSlot,
|
slot = editing.selectedSlot,
|
||||||
@@ -50,11 +36,9 @@ class MealPlanEditorViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectDate(date: LocalDate) =
|
fun selectDate(date: LocalDate) = updateEditing { it.copy(selectedDate = date) }
|
||||||
updateEditing { it.copy(selectedDate = date) }
|
|
||||||
|
|
||||||
fun setCalendarExpanded(expanded: Boolean) =
|
fun setCalendarExpanded(expanded: Boolean) = updateEditing { it.copy(calendarExpanded = expanded) }
|
||||||
updateEditing { it.copy(calendarExpanded = expanded) }
|
|
||||||
|
|
||||||
fun selectSlot(slot: MealSlot) =
|
fun selectSlot(slot: MealSlot) =
|
||||||
updateEditing {
|
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() =
|
private fun AddableIngredientUi.toAdded() =
|
||||||
AddedIngredientUi(
|
AddedIngredientUi(
|
||||||
ingredientId = ingredientId,
|
ingredientId = ingredientId,
|
||||||
@@ -118,3 +96,52 @@ class MealPlanEditorViewModel : ViewModel() {
|
|||||||
unit = defaultUnit,
|
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
|
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 MIN_RECIPE_SERVINGS = 1
|
||||||
internal const val MAX_RECIPE_SERVINGS = 12
|
internal const val MAX_RECIPE_SERVINGS = 12
|
||||||
|
|
||||||
sealed interface RecipeDetailState {
|
sealed interface RecipeDetailState {
|
||||||
data object Hidden : RecipeDetailState
|
data object NotFound : RecipeDetailState
|
||||||
|
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val recipe: RecipeDetailUi,
|
val recipe: RecipeUi,
|
||||||
val servings: Int = MIN_RECIPE_SERVINGS,
|
val servings: Int = MIN_RECIPE_SERVINGS,
|
||||||
val substitutions: Map<String, String> = emptyMap(),
|
val substitutions: Map<String, String> = emptyMap(),
|
||||||
) : RecipeDetailState
|
) : 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.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
class RecipeDetailViewModel : ViewModel() {
|
class RecipeDetailViewModel(recipeId: String) : ViewModel() {
|
||||||
private val _state = MutableStateFlow<RecipeDetailState>(RecipeDetailState.Hidden)
|
private val _state = MutableStateFlow(sampleRecipe(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.NotFound)
|
||||||
val state: StateFlow<RecipeDetailState> = _state.asStateFlow()
|
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) =
|
fun setServings(value: Int) =
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
if (current is RecipeDetailState.Ready) {
|
if (current is RecipeDetailState.Ready) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||||
|
|
||||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
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.RecipeIngredientOptionUi
|
||||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
|
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
|
||||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
|
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 BreakfastOrSnack = listOf(MealSlot.Breakfast, MealSlot.Snack)
|
||||||
private val LightMeal = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper)
|
private val LightMeal = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper)
|
||||||
|
|
||||||
internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
internal val sampleRecipes: Map<String, RecipeUi> =
|
||||||
listOf(
|
listOf(
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_nalesniki",
|
id = "rcp_nalesniki",
|
||||||
title = "Naleśniki z twarogiem",
|
title = "Naleśniki z twarogiem",
|
||||||
cookingMinutes = 25,
|
cookingMinutes = 25,
|
||||||
@@ -40,7 +41,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
|
"Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_owsianka",
|
id = "rcp_owsianka",
|
||||||
title = "Owsianka z owocami i orzechami",
|
title = "Owsianka z owocami i orzechami",
|
||||||
cookingMinutes = 10,
|
cookingMinutes = 10,
|
||||||
@@ -81,7 +82,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Polej miodem i podawaj.",
|
"Polej miodem i podawaj.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_spaghetti",
|
id = "rcp_spaghetti",
|
||||||
title = "Spaghetti bolognese",
|
title = "Spaghetti bolognese",
|
||||||
cookingMinutes = 40,
|
cookingMinutes = 40,
|
||||||
@@ -110,7 +111,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Wymieszaj sos z odsączonym makaronem i podawaj.",
|
"Wymieszaj sos z odsączonym makaronem i podawaj.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_pierogi",
|
id = "rcp_pierogi",
|
||||||
title = "Pierogi ruskie",
|
title = "Pierogi ruskie",
|
||||||
cookingMinutes = 90,
|
cookingMinutes = 90,
|
||||||
@@ -134,7 +135,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Podawaj okraszone masłem i podsmażoną cebulą.",
|
"Podawaj okraszone masłem i podsmażoną cebulą.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_kanapka_awokado",
|
id = "rcp_kanapka_awokado",
|
||||||
title = "Kanapka z awokado i jajkiem",
|
title = "Kanapka z awokado i jajkiem",
|
||||||
cookingMinutes = 5,
|
cookingMinutes = 5,
|
||||||
@@ -156,7 +157,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Ułóż plastry jajka i posyp szczypiorkiem.",
|
"Ułóż plastry jajka i posyp szczypiorkiem.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_schabowy",
|
id = "rcp_schabowy",
|
||||||
title = "Schabowy z ziemniakami",
|
title = "Schabowy z ziemniakami",
|
||||||
cookingMinutes = 60,
|
cookingMinutes = 60,
|
||||||
@@ -180,7 +181,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Podawaj z ziemniakami i ulubioną surówką.",
|
"Podawaj z ziemniakami i ulubioną surówką.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_salatka_grecka",
|
id = "rcp_salatka_grecka",
|
||||||
title = "Sałatka grecka",
|
title = "Sałatka grecka",
|
||||||
cookingMinutes = 15,
|
cookingMinutes = 15,
|
||||||
@@ -203,7 +204,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
|
"Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_pomidorowa",
|
id = "rcp_pomidorowa",
|
||||||
title = "Zupa pomidorowa z ryżem",
|
title = "Zupa pomidorowa z ryżem",
|
||||||
cookingMinutes = 35,
|
cookingMinutes = 35,
|
||||||
@@ -225,7 +226,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
|
"Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_kurczak_curry",
|
id = "rcp_kurczak_curry",
|
||||||
title = "Kurczak curry z ryżem basmati",
|
title = "Kurczak curry z ryżem basmati",
|
||||||
cookingMinutes = 45,
|
cookingMinutes = 45,
|
||||||
@@ -249,7 +250,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Podawaj z ryżem basmati.",
|
"Podawaj z ryżem basmati.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_jajecznica",
|
id = "rcp_jajecznica",
|
||||||
title = "Jajecznica na maśle ze szczypiorkiem",
|
title = "Jajecznica na maśle ze szczypiorkiem",
|
||||||
cookingMinutes = 8,
|
cookingMinutes = 8,
|
||||||
@@ -269,7 +270,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Posyp posiekanym szczypiorkiem i podawaj.",
|
"Posyp posiekanym szczypiorkiem i podawaj.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_risotto",
|
id = "rcp_risotto",
|
||||||
title = "Risotto z grzybami leśnymi",
|
title = "Risotto z grzybami leśnymi",
|
||||||
cookingMinutes = 35,
|
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.",
|
"Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_tortilla",
|
id = "rcp_tortilla",
|
||||||
title = "Tortilla z kurczakiem i warzywami",
|
title = "Tortilla z kurczakiem i warzywami",
|
||||||
cookingMinutes = 20,
|
cookingMinutes = 20,
|
||||||
@@ -315,7 +316,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
|
"Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_smoothie",
|
id = "rcp_smoothie",
|
||||||
title = "Smoothie bananowo-szpinakowe",
|
title = "Smoothie bananowo-szpinakowe",
|
||||||
cookingMinutes = 5,
|
cookingMinutes = 5,
|
||||||
@@ -341,7 +342,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Przelej do szklanki i podawaj od razu.",
|
"Przelej do szklanki i podawaj od razu.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_losos",
|
id = "rcp_losos",
|
||||||
title = "Łosoś pieczony z brokułami",
|
title = "Łosoś pieczony z brokułami",
|
||||||
cookingMinutes = 30,
|
cookingMinutes = 30,
|
||||||
@@ -363,7 +364,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
"Piecz łososia i brokuły na blasze ok. 15–18 minut.",
|
"Piecz łososia i brokuły na blasze ok. 15–18 minut.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecipeDetailUi(
|
RecipeUi(
|
||||||
id = "rcp_nadziewane_papryki",
|
id = "rcp_nadziewane_papryki",
|
||||||
title = "Papryki nadziewane kaszą i warzywami",
|
title = "Papryki nadziewane kaszą i warzywami",
|
||||||
cookingMinutes = 55,
|
cookingMinutes = 55,
|
||||||
@@ -389,7 +390,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
|
|||||||
),
|
),
|
||||||
).associateBy { it.id }
|
).associateBy { it.id }
|
||||||
|
|
||||||
internal fun sampleRecipeDetail(id: String): RecipeDetailUi? = sampleRecipeDetails[id]
|
internal fun sampleRecipe(id: String): RecipeUi? = sampleRecipes[id]
|
||||||
|
|
||||||
private fun slot(
|
private fun slot(
|
||||||
name: String,
|
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
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.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
|
import dev.ulfrx.recipe.ui.components.sheet.rememberRecipeBottomSheetState
|
||||||
import dev.ulfrx.recipe.ui.screens.mealplaneditor.PlannedMealUi
|
import dev.ulfrx.recipe.ui.screens.recipesheet.RecipeSheet
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
|
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
|
||||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import recipe.composeapp.generated.resources.Res
|
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_subtitle
|
||||||
import recipe.composeapp.generated.resources.search_screen_empty_results_title
|
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
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
viewModel: ShellSearchViewModel,
|
viewModel: ShellSearchViewModel,
|
||||||
catalogViewModel: RecipeCatalogViewModel,
|
|
||||||
detailViewModel: RecipeDetailViewModel,
|
|
||||||
editorViewModel: MealPlanEditorViewModel,
|
|
||||||
catalogGridState: LazyGridState,
|
catalogGridState: LazyGridState,
|
||||||
onPlanConfirmed: (PlannedMealUi) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
|
val catalogViewModel: RecipeCatalogViewModel = koinViewModel()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
|
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val bottomSheetState = rememberRecipeBottomSheetState<Screen>()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -62,16 +60,12 @@ fun SearchScreen(
|
|||||||
} else {
|
} else {
|
||||||
RecipeCatalogGrid(
|
RecipeCatalogGrid(
|
||||||
state = catalogState,
|
state = catalogState,
|
||||||
onRecipeClick = detailViewModel::open,
|
onRecipeClick = { bottomSheetState.open(Screen.RecipeDetail(it)) },
|
||||||
gridState = catalogGridState,
|
gridState = catalogGridState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
RecipeDetailSheet(
|
RecipeSheet(state = bottomSheetState)
|
||||||
detailViewModel = detailViewModel,
|
|
||||||
editorViewModel = editorViewModel,
|
|
||||||
onPlanConfirmed = onPlanConfirmed,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.glass.rememberGlassBackdropState
|
||||||
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
|
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
|
||||||
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
|
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.SearchScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
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 dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@@ -38,9 +35,6 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
fun AppShell(modifier: Modifier = Modifier) {
|
fun AppShell(modifier: Modifier = Modifier) {
|
||||||
val navigator = remember { TabNavigator() }
|
val navigator = remember { TabNavigator() }
|
||||||
val searchVm: ShellSearchViewModel = koinViewModel()
|
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||||
val catalogVm: RecipeCatalogViewModel = koinViewModel()
|
|
||||||
val detailVm: RecipeDetailViewModel = koinViewModel()
|
|
||||||
val editorVm: MealPlanEditorViewModel = koinViewModel()
|
|
||||||
val catalogGridState = rememberLazyGridState()
|
val catalogGridState = rememberLazyGridState()
|
||||||
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
||||||
val backdropState = rememberGlassBackdropState()
|
val backdropState = rememberGlassBackdropState()
|
||||||
@@ -72,9 +66,6 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
if (searchOpen) {
|
if (searchOpen) {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
viewModel = searchVm,
|
viewModel = searchVm,
|
||||||
catalogViewModel = catalogVm,
|
|
||||||
detailViewModel = detailVm,
|
|
||||||
editorViewModel = editorVm,
|
|
||||||
catalogGridState = catalogGridState,
|
catalogGridState = catalogGridState,
|
||||||
)
|
)
|
||||||
} else {
|
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,6 +92,7 @@ lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref =
|
|||||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
||||||
|
|
||||||
navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
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-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
|
||||||
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
|
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
|
||||||
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
||||||
|
|||||||
Reference in New Issue
Block a user