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" }