diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 4315528..0bb288f 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -93,6 +93,7 @@ kotlin {
implementation(libs.multiplatform.settings)
implementation(libs.lokksmith.compose)
implementation(libs.navigation3.ui)
+ implementation(libs.androidx.lifecycle.viewmodelNavigation3)
implementation(libs.compose.unstyled)
implementation(libs.compose.icons.lucide)
implementation(libs.liquid)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 0e5f0b4..bd5745f 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -48,7 +48,9 @@
%1$d.
Zmniejsz liczbę porcji
Zwiększ liczbę porcji
- Przeciągnij, aby zamknąć szczegóły przepisu
+ Przeciągnij w dół, aby zamknąć
+ Nie znaleziono przepisu
+ Nie udało się otworzyć edytora
Otwórz wyszukiwanie
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
index 1eb023b..a3da7cc 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
@@ -1,13 +1,16 @@
package dev.ulfrx.recipe.di
+import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
+import dev.ulfrx.recipe.ui.screens.recipedetail.sampleRecipe
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
+import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
@@ -19,6 +22,16 @@ val shellModule =
viewModel()
viewModel()
viewModel()
- viewModel()
- viewModel()
+
+ 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 },
+ )
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/MealPlanEditorSource.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/MealPlanEditorSource.kt
new file mode 100644
index 0000000..5db8b75
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/MealPlanEditorSource.kt
@@ -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 = emptyMap(),
+ ) : MealPlanEditorSource
+
+ @Serializable
+ data class EditExistingPlan(val plannedMealId: String) : MealPlanEditorSource
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt
index 3ce8c11..6809016 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt
@@ -4,16 +4,9 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
/**
- * Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
- * back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration).
- *
- * Screens are grouped by tab so future detail destinations slot in without
- * polluting the top-level namespace — e.g. `Screen.Pantry.Detail(id)`. The
- * grouping is purely a code-organisation convenience; Nav 3 treats each leaf as
- * an independent NavKey regardless of nesting.
- *
- * The Recipes catalog has no own tab — it is reached via the shell-wide search
- * destination (see `ShellSearchViewModel`).
+ * Each leaf is `@Serializable` because Nav 3 persists the back stack via
+ * kotlinx-serialization (process-death restore). Recipes have no tab — they
+ * land in [RecipeDetail] via the shell-wide search overlay.
*/
sealed interface Screen : NavKey {
sealed interface Home : Screen {
@@ -35,4 +28,12 @@ sealed interface Screen : NavKey {
@Serializable
data object Home : Shopping
}
+
+ @Serializable
+ data class RecipeDetail(val recipeId: String) : Screen
+
+ sealed interface MealPlanEditor : Screen {
+ @Serializable
+ data class Open(val source: MealPlanEditorSource) : MealPlanEditor
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeUi.kt
new file mode 100644
index 0000000..5ef1b1b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeUi.kt
@@ -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,
+ val steps: List,
+ val allowedSlots: List,
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheet.kt
new file mode 100644
index 0000000..0296ef2
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheet.kt
@@ -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 RecipeBottomSheet(
+ state: RecipeBottomSheetState,
+ modifier: Modifier = Modifier,
+ entries: EntryProviderScope.() -> Unit,
+) {
+ val modalSheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.Hidden, detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded))
+ val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator()
+ val viewModelDecorator = rememberViewModelStoreNavEntryDecorator()
+
+ 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 EmitDismissOnUserCancel(
+ modalSheetState: ModalBottomSheetState,
+ state: RecipeBottomSheetState,
+) {
+ 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheetState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheetState.kt
new file mode 100644
index 0000000..8ae6af7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheetState.kt
@@ -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 {
+ val backStack: SnapshotStateList = 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 rememberRecipeBottomSheetState(): RecipeBottomSheetState =
+ remember { RecipeBottomSheetState() }
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt
index a966af3..8097b06 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt
@@ -50,12 +50,6 @@ import recipe.composeapp.generated.resources.nutrition_label
import recipe.composeapp.generated.resources.recipe_detail_servings_decrement_a11y
import recipe.composeapp.generated.resources.recipe_detail_servings_increment_a11y
-/**
- * Scrollable body of the meal-plan editor. Stays a pure stateless renderer —
- * top floating actions (back, confirm) and the sheet handle live one level up
- * inside [RecipeDetailSheet] so both detail and editor content composables can
- * share the same chrome.
- */
@Composable
internal fun MealPlanEditorContent(
editing: MealPlanEditorState.Editing,
@@ -104,10 +98,8 @@ internal fun MealPlanEditorContent(
.verticalScroll(scrollState, enabled = !addPanelOpen),
) {
Spacer(Modifier.height(topChromeInset))
- // Title sits in a row whose vertical bounds match the floating chrome
- // (back/add buttons) so at scroll=0 it reads as the centre of the
- // toolbar. Horizontal inset clears the chrome buttons — they are
- // square pills of [topChromeHeight] anchored at spacing.lg.
+ // Aligns the title row with the floating back/confirm chrome at
+ // scroll=0: same height, padded inside the chrome's circle pills.
Box(
modifier =
Modifier
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorEntry.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorEntry.kt
new file mode 100644
index 0000000..b274fa4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorEntry.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt
index 975166e..6e6f5ec 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt
@@ -1,17 +1,18 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
-import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
+import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import kotlinx.datetime.LocalDate
internal const val MIN_PLAN_SERVINGS = 1
internal const val MAX_PLAN_SERVINGS = 12
sealed interface MealPlanEditorState {
- data object Hidden : MealPlanEditorState
+ data object NotFound : MealPlanEditorState
data class Editing(
- val recipe: RecipeDetailUi,
+ val id: String,
+ val recipe: RecipeUi,
val selectedDate: LocalDate,
val selectedSlot: MealSlot,
val calendarExpanded: Boolean = false,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt
index b088791..1aacfc7 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt
@@ -3,11 +3,6 @@ package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import kotlinx.datetime.LocalDate
-/**
- * Ingredient appended to a recipe inside the editor (not part of the recipe's
- * original ingredient list). Removed by id — never deduped into the recipe's
- * exclusion set.
- */
data class AddedIngredientUi(
val ingredientId: String,
val name: String,
@@ -15,7 +10,6 @@ data class AddedIngredientUi(
val unit: String,
)
-/** Catalog entry shown in the "Dodaj składnik" search panel. */
data class AddableIngredientUi(
val ingredientId: String,
val name: String,
@@ -23,12 +17,8 @@ data class AddableIngredientUi(
val defaultUnit: String,
)
-/**
- * Payload emitted by [MealPlanEditorViewModel.confirm] when the user adds the
- * meal to the plan. Persistence to `planStore` and the sync engine lands in
- * Phase 6+; the editor itself produces this value-type only.
- */
data class PlannedMealUi(
+ val id: String,
val recipeId: String,
val date: LocalDate,
val slot: MealSlot,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt
index 1373d13..4482e03 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt
@@ -1,45 +1,31 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.lifecycle.ViewModel
+import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
+import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import dev.ulfrx.recipe.ui.components.recipe.options
-import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
-class MealPlanEditorViewModel : ViewModel() {
- private val _state = MutableStateFlow(MealPlanEditorState.Hidden)
+class MealPlanEditorViewModel(
+ source: MealPlanEditorSource,
+ recipeProvider: (String) -> RecipeUi?,
+ plannedMealProvider: (String) -> PlannedMealUi?,
+) : ViewModel() {
+ private val _state = MutableStateFlow(loadInitial(source, recipeProvider, plannedMealProvider))
val state: StateFlow = _state.asStateFlow()
- fun open(
- recipe: RecipeDetailUi,
- initialSubstitutions: Map = emptyMap(),
- initialServings: Int = MIN_PLAN_SERVINGS,
- initialDate: LocalDate = todayInSystemTz(),
- ) {
- val slot = recipe.allowedSlots.firstOrNull() ?: MealSlot.entries.first()
- _state.value =
- MealPlanEditorState.Editing(
- recipe = recipe,
- selectedDate = initialDate,
- selectedSlot = slot,
- servings = initialServings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
- substitutions = initialSubstitutions.filterValid(recipe),
- )
- }
-
- fun close() {
- _state.value = MealPlanEditorState.Hidden
- }
-
fun confirm(): PlannedMealUi? {
val editing = _state.value as? MealPlanEditorState.Editing ?: return null
- _state.value = MealPlanEditorState.Hidden
return PlannedMealUi(
+ id = editing.id,
recipeId = editing.recipe.id,
date = editing.selectedDate,
slot = editing.selectedSlot,
@@ -50,11 +36,9 @@ class MealPlanEditorViewModel : ViewModel() {
)
}
- fun selectDate(date: LocalDate) =
- updateEditing { it.copy(selectedDate = date) }
+ fun selectDate(date: LocalDate) = updateEditing { it.copy(selectedDate = date) }
- fun setCalendarExpanded(expanded: Boolean) =
- updateEditing { it.copy(calendarExpanded = expanded) }
+ fun setCalendarExpanded(expanded: Boolean) = updateEditing { it.copy(calendarExpanded = expanded) }
fun selectSlot(slot: MealSlot) =
updateEditing {
@@ -104,12 +88,6 @@ class MealPlanEditorViewModel : ViewModel() {
}
}
- private fun Map.filterValid(recipe: RecipeDetailUi): Map =
- filter { (slotId, optionId) ->
- val slot = recipe.ingredients.firstOrNull { it.id == slotId }
- slot != null && slot.options.any { it.id == optionId }
- }
-
private fun AddableIngredientUi.toAdded() =
AddedIngredientUi(
ingredientId = ingredientId,
@@ -118,3 +96,52 @@ class MealPlanEditorViewModel : ViewModel() {
unit = defaultUnit,
)
}
+
+@OptIn(ExperimentalUuidApi::class)
+private fun loadInitial(
+ source: MealPlanEditorSource,
+ recipeProvider: (String) -> RecipeUi?,
+ plannedMealProvider: (String) -> PlannedMealUi?,
+): MealPlanEditorState =
+ when (source) {
+ is MealPlanEditorSource.NewFromRecipe -> {
+ val recipe = recipeProvider(source.recipeId)
+ if (recipe == null) {
+ MealPlanEditorState.NotFound
+ } else {
+ MealPlanEditorState.Editing(
+ id = "plan_${Uuid.random()}",
+ recipe = recipe,
+ selectedDate = todayInSystemTz(),
+ selectedSlot = recipe.allowedSlots.firstOrNull() ?: MealSlot.entries.first(),
+ servings = source.initialServings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
+ substitutions = source.initialSubstitutions.filterValid(recipe),
+ )
+ }
+ }
+
+ is MealPlanEditorSource.EditExistingPlan -> {
+ val planned = plannedMealProvider(source.plannedMealId)
+ val recipe = planned?.let { recipeProvider(it.recipeId) }
+ if (planned == null || recipe == null) {
+ MealPlanEditorState.NotFound
+ } else {
+ MealPlanEditorState.Editing(
+ id = planned.id,
+ recipe = recipe,
+ selectedDate = planned.date,
+ selectedSlot = planned.slot,
+ servings = planned.servings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
+ substitutions = planned.substitutions.filterValid(recipe),
+ excludedIngredients = planned.excludedIngredients,
+ addedIngredients = planned.addedIngredients,
+ )
+ }
+ }
+ }
+
+private fun Map.filterValid(recipe: RecipeUi): Map =
+ filter { (slotId, optionId) ->
+ val slot = recipe.ingredients.firstOrNull { it.id == slotId }
+ slot != null && slot.options.any { it.id == optionId }
+ }
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailContent.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailContent.kt
new file mode 100644
index 0000000..18189a8
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailContent.kt
@@ -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,
+ servings: Int,
+ substitutions: Map,
+ 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) {
+ 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailScreen.kt
new file mode 100644
index 0000000..adac137
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailScreen.kt
@@ -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,
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
deleted file mode 100644
index a121b49..0000000
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
+++ /dev/null
@@ -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,
- servings: Int,
- substitutions: Map,
- 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) {
- 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt
index 93447c4..24765e0 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt
@@ -1,13 +1,15 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
+import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
+
internal const val MIN_RECIPE_SERVINGS = 1
internal const val MAX_RECIPE_SERVINGS = 12
sealed interface RecipeDetailState {
- data object Hidden : RecipeDetailState
+ data object NotFound : RecipeDetailState
data class Ready(
- val recipe: RecipeDetailUi,
+ val recipe: RecipeUi,
val servings: Int = MIN_RECIPE_SERVINGS,
val substitutions: Map = emptyMap(),
) : RecipeDetailState
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt
deleted file mode 100644
index cdd5831..0000000
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt
+++ /dev/null
@@ -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,
- val steps: List,
- val allowedSlots: List,
-)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt
index b3af8b6..18b9841 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt
@@ -7,18 +7,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
-class RecipeDetailViewModel : ViewModel() {
- private val _state = MutableStateFlow(RecipeDetailState.Hidden)
+class RecipeDetailViewModel(recipeId: String) : ViewModel() {
+ private val _state = MutableStateFlow(sampleRecipe(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.NotFound)
val state: StateFlow = _state.asStateFlow()
- fun open(recipeId: String) {
- _state.value = sampleRecipeDetail(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.Hidden
- }
-
- fun dismiss() {
- _state.value = RecipeDetailState.Hidden
- }
-
fun setServings(value: Int) =
_state.update { current ->
if (current is RecipeDetailState.Ready) {
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipes.kt
similarity index 97%
rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt
rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipes.kt
index b4b68b0..5bb501b 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipes.kt
@@ -1,6 +1,7 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
+import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
@@ -9,9 +10,9 @@ private val LunchOrDinner = listOf(MealSlot.Lunch, MealSlot.Dinner)
private val BreakfastOrSnack = listOf(MealSlot.Breakfast, MealSlot.Snack)
private val LightMeal = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper)
-internal val sampleRecipeDetails: Map =
+internal val sampleRecipes: Map =
listOf(
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_nalesniki",
title = "Naleśniki z twarogiem",
cookingMinutes = 25,
@@ -40,7 +41,7 @@ internal val sampleRecipeDetails: Map =
"Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami",
cookingMinutes = 10,
@@ -81,7 +82,7 @@ internal val sampleRecipeDetails: Map =
"Polej miodem i podawaj.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_spaghetti",
title = "Spaghetti bolognese",
cookingMinutes = 40,
@@ -110,7 +111,7 @@ internal val sampleRecipeDetails: Map =
"Wymieszaj sos z odsączonym makaronem i podawaj.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_pierogi",
title = "Pierogi ruskie",
cookingMinutes = 90,
@@ -134,7 +135,7 @@ internal val sampleRecipeDetails: Map =
"Podawaj okraszone masłem i podsmażoną cebulą.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem",
cookingMinutes = 5,
@@ -156,7 +157,7 @@ internal val sampleRecipeDetails: Map =
"Ułóż plastry jajka i posyp szczypiorkiem.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_schabowy",
title = "Schabowy z ziemniakami",
cookingMinutes = 60,
@@ -180,7 +181,7 @@ internal val sampleRecipeDetails: Map =
"Podawaj z ziemniakami i ulubioną surówką.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_salatka_grecka",
title = "Sałatka grecka",
cookingMinutes = 15,
@@ -203,7 +204,7 @@ internal val sampleRecipeDetails: Map =
"Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem",
cookingMinutes = 35,
@@ -225,7 +226,7 @@ internal val sampleRecipeDetails: Map =
"Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati",
cookingMinutes = 45,
@@ -249,7 +250,7 @@ internal val sampleRecipeDetails: Map =
"Podawaj z ryżem basmati.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem",
cookingMinutes = 8,
@@ -269,7 +270,7 @@ internal val sampleRecipeDetails: Map =
"Posyp posiekanym szczypiorkiem i podawaj.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_risotto",
title = "Risotto z grzybami leśnymi",
cookingMinutes = 35,
@@ -292,7 +293,7 @@ internal val sampleRecipeDetails: Map =
"Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami",
cookingMinutes = 20,
@@ -315,7 +316,7 @@ internal val sampleRecipeDetails: Map =
"Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe",
cookingMinutes = 5,
@@ -341,7 +342,7 @@ internal val sampleRecipeDetails: Map =
"Przelej do szklanki i podawaj od razu.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_losos",
title = "Łosoś pieczony z brokułami",
cookingMinutes = 30,
@@ -363,7 +364,7 @@ internal val sampleRecipeDetails: Map =
"Piecz łososia i brokuły na blasze ok. 15–18 minut.",
),
),
- RecipeDetailUi(
+ RecipeUi(
id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami",
cookingMinutes = 55,
@@ -389,7 +390,7 @@ internal val sampleRecipeDetails: Map =
),
).associateBy { it.id }
-internal fun sampleRecipeDetail(id: String): RecipeDetailUi? = sampleRecipeDetails[id]
+internal fun sampleRecipe(id: String): RecipeUi? = sampleRecipes[id]
private fun slot(
name: String,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipesheet/RecipeSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipesheet/RecipeSheet.kt
new file mode 100644
index 0000000..9473a92
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipesheet/RecipeSheet.kt
@@ -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) {
+ RecipeBottomSheet(state = state) {
+ entry { 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 { key ->
+ val vm: MealPlanEditorViewModel = koinViewModel { parametersOf(key.source) }
+
+ MealPlanEditorScreen(
+ viewModel = vm,
+ onBack = state::pop,
+ onConfirm = { _ ->
+ // TODO Phase 6: persist via PlannedMealsRepository
+ state.dismiss()
+ },
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
index 1705e4d..18d5032 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
@@ -14,15 +14,15 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
+import dev.ulfrx.recipe.navigation.Screen
import dev.ulfrx.recipe.ui.components.empty.EmptyState
-import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
-import dev.ulfrx.recipe.ui.screens.mealplaneditor.PlannedMealUi
-import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
-import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
+import dev.ulfrx.recipe.ui.components.sheet.rememberRecipeBottomSheetState
+import dev.ulfrx.recipe.ui.screens.recipesheet.RecipeSheet
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle
import recipe.composeapp.generated.resources.search_screen_empty_results_title
@@ -30,14 +30,12 @@ import recipe.composeapp.generated.resources.search_screen_empty_results_title
@Composable
fun SearchScreen(
viewModel: ShellSearchViewModel,
- catalogViewModel: RecipeCatalogViewModel,
- detailViewModel: RecipeDetailViewModel,
- editorViewModel: MealPlanEditorViewModel,
catalogGridState: LazyGridState,
- onPlanConfirmed: (PlannedMealUi) -> Unit = {},
) {
+ val catalogViewModel: RecipeCatalogViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
+ val bottomSheetState = rememberRecipeBottomSheetState()
Box(
modifier =
@@ -62,16 +60,12 @@ fun SearchScreen(
} else {
RecipeCatalogGrid(
state = catalogState,
- onRecipeClick = detailViewModel::open,
+ onRecipeClick = { bottomSheetState.open(Screen.RecipeDetail(it)) },
gridState = catalogGridState,
modifier = Modifier.fillMaxSize(),
)
}
- RecipeDetailSheet(
- detailViewModel = detailViewModel,
- editorViewModel = editorViewModel,
- onPlanConfirmed = onPlanConfirmed,
- )
+ RecipeSheet(state = bottomSheetState)
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
index 35c2325..ca167bc 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
@@ -25,11 +25,8 @@ import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
-import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
-import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
-import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel
@@ -38,9 +35,6 @@ import org.koin.compose.viewmodel.koinViewModel
fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel()
- val catalogVm: RecipeCatalogViewModel = koinViewModel()
- val detailVm: RecipeDetailViewModel = koinViewModel()
- val editorVm: MealPlanEditorViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState()
@@ -72,9 +66,6 @@ fun AppShell(modifier: Modifier = Modifier) {
if (searchOpen) {
SearchScreen(
viewModel = searchVm,
- catalogViewModel = catalogVm,
- detailViewModel = detailVm,
- editorViewModel = editorVm,
catalogGridState = catalogGridState,
)
} else {
diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModelTest.kt
deleted file mode 100644
index 9912b52..0000000
--- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModelTest.kt
+++ /dev/null
@@ -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 = 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> = 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",
- )
-}
diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModelTest.kt
deleted file mode 100644
index fe88398..0000000
--- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModelTest.kt
+++ /dev/null
@@ -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
- }
-}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ec82715..158d648 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -92,6 +92,7 @@ lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref =
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
+androidx-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }