diff --git a/CLAUDE.md b/CLAUDE.md index f843998..28e5b0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,14 +74,16 @@ dev.ulfrx.recipe/ ├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab) ├── ui/ │ ├── theme/ # Colors, typography, Liquid glass style tokens -│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful -│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel +│ ├── components/ # Shared, stateless (VM-free) Recipe-styled composables built on Compose Unstyled where useful +│ └── screens/{recipes,planner,pantry,shopping,recipedetail}/ # Each with screen + ViewModel ├── data/{local,remote,repository}/ └── domain/ # Client-only logic; shared/ handles cross-cutting ``` **Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above. +**Rule:** A `screens/` package is a *stateful* UI feature (screen + ViewModel), not necessarily a nav route. `recipedetail` presents as a modal bottom sheet and is opened from multiple hosts (search, later planner) — it lives under `screens/` because it owns a ViewModel, while its leaf widgets (`IngredientRow`, `NutritionSummary`) stay in `components/`, which is reserved for stateless, VM-free composables. + ## Non-negotiable conventions 1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5469504..4315528 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -108,6 +108,9 @@ kotlin { // ASWebAuthenticationSession integration directly from Kotlin. implementation(libs.ktor.clientDarwin) } + commonTest.dependencies { + implementation(libs.kotlin.test) + } } } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b356e49..5ec1345 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -30,6 +30,27 @@ %1$d min %1$d kcal + + Wartości odżywcze + kcal + białko + tłuszcz + węglowodany + %1$dg + + + Zamień składnik + + + Zaplanuj + Porcje + Składniki + Kroki + %1$d. + Zmniejsz liczbę porcji + Zwiększ liczbę porcji + Przeciągnij, aby zamknąć szczegóły przepisu + Otwórz wyszukiwanie Wyczyść i ukryj klawiaturę 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 a9e8049..00ec737 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt @@ -5,6 +5,7 @@ import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel +import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel import org.koin.dsl.module import org.koin.plugin.module.dsl.viewModel @@ -17,4 +18,5 @@ val shellModule = viewModel() viewModel() viewModel() + viewModel() } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt index 8089557..8889412 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt @@ -45,9 +45,8 @@ import org.koin.compose.viewmodel.koinViewModel * during cross-fade — matches the previous Nav-2 multi-back-stack behaviour * (`saveState=true`/`restoreState=true`). * - * Phase 5+ introduces detail screens with their own VM scopes; at that point - * we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries - * specifically (passed via `entryDecorators = listOf(...)`). + * Recipe detail is a modal bottom sheet (not a nav destination), so it needs no + * per-entry VM scope here; its VM is hosted by the surface that opens it. * * ## Search note * Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt new file mode 100644 index 0000000..e2e3e3f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt @@ -0,0 +1,283 @@ +package dev.ulfrx.recipe.ui.components.recipe + +import androidx.compose.animation.animateContentSize +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composables.icons.lucide.Check +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Shuffle +import com.composeunstyled.UnstyledButton +import com.composeunstyled.UnstyledIcon +import dev.ulfrx.recipe.ui.theme.RecipeTheme +import org.jetbrains.compose.resources.stringResource +import recipe.composeapp.generated.resources.Res +import recipe.composeapp.generated.resources.ingredient_substitute_a11y +import kotlin.math.round + +data class RecipeIngredientOptionUi( + val id: String, + val name: String, + val amount: Double, + val unit: String, +) + +data class RecipeIngredientSlotUi( + val default: RecipeIngredientOptionUi, + val alternatives: List = emptyList(), + val id: String = default.id, +) + +@Composable +fun IngredientRow( + slot: RecipeIngredientSlotUi, + modifier: Modifier = Modifier, + selectedOptionId: String = slot.default.id, + onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null, +) { + val colors = RecipeTheme.colors + val typography = RecipeTheme.typography + val options = slot.options + val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default + val swappable = slot.alternatives.isNotEmpty() && onSelect != null + var expanded by remember(slot.id) { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CornerRadius)) + .background(colors.surface) + .animateContentSize(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = PaddingHorizontal, vertical = PaddingVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + ) { + BasicText( + text = selected.name, + style = + typography.body.copy( + color = colors.content, + fontWeight = FontWeight.Medium, + fontSize = NameTextSize, + lineHeight = LineHeight, + ), + modifier = Modifier.weight(1f), + ) + if (swappable) { + SwapToggle(onClick = { expanded = !expanded }) + } + AmountLabel(amount = selected.amount, unit = selected.unit) + } + + if (swappable && expanded) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = PaddingHorizontal, end = PaddingHorizontal, bottom = PaddingVertical), + verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + ) { + options.forEach { option -> + AlternativeOption( + option = option, + selected = option.id == selected.id, + onClick = { + onSelect(option) + expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun AmountLabel( + amount: Double, + unit: String, +) { + val colors = RecipeTheme.colors + val typography = RecipeTheme.typography + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs), + ) { + BasicText( + text = formatAmount(amount), + style = + typography.body.copy( + color = colors.content, + fontWeight = FontWeight.Medium, + fontSize = NameTextSize, + lineHeight = LineHeight, + ), + ) + BasicText( + text = unit, + style = + typography.body.copy( + color = colors.contentMuted, + fontSize = UnitTextSize, + lineHeight = LineHeight, + ), + ) + } +} + +@Composable +private fun SwapToggle(onClick: () -> Unit) { + val colors = RecipeTheme.colors + UnstyledButton( + onClick = onClick, + modifier = Modifier.size(ToggleSize), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + UnstyledIcon( + imageVector = Lucide.Shuffle, + contentDescription = stringResource(Res.string.ingredient_substitute_a11y), + tint = colors.contentMuted, + modifier = Modifier.size(ToggleIconSize), + ) + } + } +} + +@Composable +private fun AlternativeOption( + option: RecipeIngredientOptionUi, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = RecipeTheme.colors + val typography = RecipeTheme.typography + UnstyledButton( + onClick = onClick, + backgroundColor = colors.background, + contentColor = colors.content, + shape = RoundedCornerShape(OptionCornerRadius), + contentPadding = PaddingValues(OptionPadding), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + ) { + Column(modifier = Modifier.weight(1f)) { + BasicText( + text = option.name, + style = + typography.body.copy( + color = colors.content, + fontWeight = FontWeight.Medium, + fontSize = OptionNameTextSize, + lineHeight = OptionNameLineHeight, + ), + ) + Spacer(Modifier.height(OptionMetaGap)) + BasicText( + text = formatAmount(option.amount) + " " + option.unit, + style = + typography.body.copy( + color = colors.contentMuted, + fontSize = OptionMetaTextSize, + lineHeight = OptionMetaLineHeight, + ), + ) + } + SelectionMark(selected = selected) + } + } +} + +@Composable +private fun SelectionMark(selected: Boolean) { + val colors = RecipeTheme.colors + Box( + modifier = + Modifier + .size(SelectionMarkSize) + .clip(RoundedCornerShape(percent = 50)) + .border( + width = SelectionMarkBorder, + color = colors.separator, + shape = RoundedCornerShape(percent = 50), + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + UnstyledIcon( + imageVector = Lucide.Check, + contentDescription = null, + tint = colors.contentMuted, + modifier = Modifier.size(SelectionCheckSize), + ) + } + } +} + +private fun formatAmount(value: Double): String { + val scaled = round(value * 10.0).toLong() + val whole = scaled / 10 + val frac = (scaled % 10).toInt() + return if (frac == 0) whole.toString() else "$whole,$frac" +} + +internal val RecipeIngredientSlotUi.options: List + get() = listOf(default) + alternatives + +internal fun RecipeIngredientSlotUi.scaledBy(servings: Int) = + RecipeIngredientSlotUi( + default = default.copy(amount = default.amount * servings), + alternatives = alternatives.map { it.copy(amount = it.amount * servings) }, + id = id, + ) + +private val CornerRadius = 12.dp +private val PaddingHorizontal = 12.dp +private val PaddingVertical = 16.dp +private val NameTextSize = 13.sp +private val UnitTextSize = 12.sp +private val LineHeight = 18.sp +private val ToggleSize = 28.dp +private val ToggleIconSize = 14.dp +private val OptionCornerRadius = 10.dp +private val OptionPadding = 12.dp +private val OptionMetaGap = 2.dp +private val OptionNameTextSize = 11.sp +private val OptionNameLineHeight = 14.sp +private val OptionMetaTextSize = 10.sp +private val OptionMetaLineHeight = 13.sp +private val SelectionMarkSize = 18.dp +private val SelectionMarkBorder = 1.5.dp +private val SelectionCheckSize = 10.dp diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt new file mode 100644 index 0000000..293847b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt @@ -0,0 +1,114 @@ +package dev.ulfrx.recipe.ui.components.recipe + +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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_grams_format +import recipe.composeapp.generated.resources.nutrition_macro_carbs +import recipe.composeapp.generated.resources.nutrition_macro_fat +import recipe.composeapp.generated.resources.nutrition_macro_kcal +import recipe.composeapp.generated.resources.nutrition_macro_protein + +data class RecipeNutritionUi( + val kcal: Int, + val protein: Int, + val fat: Int, + val carbs: Int, +) + +internal fun RecipeNutritionUi.scaledBy(servings: Int) = + RecipeNutritionUi( + kcal = kcal * servings, + protein = protein * servings, + fat = fat * servings, + carbs = carbs * servings, + ) + +@Composable +fun NutritionSummary( + nutrition: RecipeNutritionUi, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + ) { + MacroCard( + modifier = Modifier.weight(1f), + value = nutrition.kcal.toString(), + label = stringResource(Res.string.nutrition_macro_kcal), + ) + MacroCard( + modifier = Modifier.weight(1f), + value = stringResource(Res.string.nutrition_grams_format, nutrition.protein), + label = stringResource(Res.string.nutrition_macro_protein), + ) + MacroCard( + modifier = Modifier.weight(1f), + value = stringResource(Res.string.nutrition_grams_format, nutrition.fat), + label = stringResource(Res.string.nutrition_macro_fat), + ) + MacroCard( + modifier = Modifier.weight(1f), + value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs), + label = stringResource(Res.string.nutrition_macro_carbs), + ) + } +} + +@Composable +private fun MacroCard( + value: String, + label: String, + modifier: Modifier = Modifier, +) { + val colors = RecipeTheme.colors + Column( + modifier = + modifier + .clip(RoundedCornerShape(CardCornerRadius)) + .background(colors.surface) + .padding(vertical = RecipeTheme.spacing.sm, horizontal = RecipeTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BasicText( + text = value, + style = + RecipeTheme.typography.body.copy( + color = colors.content, + fontWeight = FontWeight.Bold, + fontSize = ValueTextSize, + ), + ) + Spacer(Modifier.height(RecipeTheme.spacing.xs)) + BasicText( + text = label, + style = + RecipeTheme.typography.label.copy( + color = colors.contentMuted, + fontSize = LabelTextSize, + fontWeight = FontWeight.Normal, + ), + ) + } +} + +private val CardCornerRadius = 12.dp +private val ValueTextSize = 16.sp +private val LabelTextSize = 11.sp 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 new file mode 100644 index 0000000..f70f67a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt @@ -0,0 +1,509 @@ +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.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.size +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +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.Calendar +import com.composables.icons.lucide.Clock +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Minus +import com.composables.icons.lucide.Plus +import com.composeunstyled.UnstyledButton +import com.composeunstyled.UnstyledIcon +import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource +import dev.ulfrx.recipe.ui.components.glass.GlassSurface +import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState +import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState +import dev.ulfrx.recipe.ui.components.recipe.IngredientRow +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.recipe.scaledBy +import dev.ulfrx.recipe.ui.theme.RecipeTheme +import org.jetbrains.compose.resources.painterResource +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_card_minutes_format +import recipe.composeapp.generated.resources.recipe_detail_handle_a11y +import recipe.composeapp.generated.resources.recipe_detail_plan_button +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 +import recipe.composeapp.generated.resources.sample_recipe + +@Composable +fun RecipeDetailSheet( + viewModel: RecipeDetailViewModel, + onPlanRecipe: (recipeId: String) -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val sheetState = + rememberModalBottomSheetState( + initialDetent = SheetDetent.Hidden, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), + ) + val ready = state as? RecipeDetailState.Ready + + LaunchedEffect(ready != null) { + sheetState.targetDetent = if (ready != null) SheetDetent.FullyExpanded else SheetDetent.Hidden + } + + // Only caller of dismiss(): a drag that settles the sheet at Hidden while the VM still holds + // the recipe. 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 && ready != null) { + viewModel.dismiss() + } + } + + ModalBottomSheet(state = sheetState) { + Scrim( + scrimColor = ScrimColor, + enter = fadeIn(tween(ScrimFadeMillis)), + exit = fadeOut(tween(ScrimFadeMillis)), + ) + Sheet( + modifier = Modifier.fillMaxWidth(), + backgroundColor = RecipeTheme.colors.background, + shape = RoundedCornerShape(topStart = SheetCornerRadius, topEnd = SheetCornerRadius), + ) { + ready?.let { + RecipeDetailContent( + ready = it, + onServingsChange = viewModel::setServings, + onSelectSubstitution = viewModel::selectSubstitution, + onPlanRecipe = onPlanRecipe, + ) + } + } + } +} + +@Composable +private fun BottomSheetScope.RecipeDetailContent( + ready: RecipeDetailState.Ready, + onServingsChange: (Int) -> Unit, + onSelectSubstitution: (String, String) -> Unit, + onPlanRecipe: (String) -> Unit, +) { + val colors = RecipeTheme.colors + val typography = RecipeTheme.typography + val spacing = RecipeTheme.spacing + val scrollState = rememberScrollState() + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val handleLabel = stringResource(Res.string.recipe_detail_handle_a11y) + + val backdrop = rememberGlassBackdropState() + + val detail = ready.recipe + val servings = ready.servings + + CompositionLocalProvider(LocalGlassBackdropState provides backdrop) { + Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) { + GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + RecipeHero() + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) { + BasicText( + text = detail.title, + style = + typography.display.copy( + color = colors.content, + fontSize = TitleTextSize, + lineHeight = TitleLineHeight, + fontWeight = FontWeight.Bold, + ), + ) + Spacer(Modifier.height(spacing.sm)) + MetaRow(minutes = detail.cookingMinutes) + + 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)) + } + } + } + + DragIndication( + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = spacing.sm) + .semantics { contentDescription = handleLabel } + .clip(RoundedCornerShape(percent = 50)) + .background(colors.surface.copy(alpha = 0.85f)) + .width(HandleWidth) + .height(HandleHeight), + ) + + PlanButton( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = spacing.lg, end = spacing.lg), + onClick = { onPlanRecipe(detail.id) }, + ) + } + } +} + +@Composable +private fun RecipeHero() { + val colors = RecipeTheme.colors + Box(modifier = Modifier.fillMaxWidth().height(HeroHeight)) { + Box(modifier = Modifier.fillMaxSize().background(colors.surfaceGlass)) + Image( + painter = painterResource(Res.drawable.sample_recipe), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = + arrayOf( + 0.5f to Color.Transparent, + 1f to colors.background, + ), + ), + ), + ) + } +} + +@Composable +private fun MetaRow(minutes: Int) { + val colors = RecipeTheme.colors + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs), + ) { + UnstyledIcon( + imageVector = Lucide.Clock, + contentDescription = null, + tint = colors.contentMuted, + modifier = Modifier.size(MetaIconSize), + ) + BasicText( + text = stringResource(Res.string.recipe_card_minutes_format, minutes), + style = RecipeTheme.typography.label.copy(color = colors.contentMuted), + ) + } +} + +@Composable +private fun Section( + title: String, + content: @Composable () -> Unit, +) { + SectionTitle(text = title) + Spacer(Modifier.height(RecipeTheme.spacing.lg)) + content() +} + +@Composable +private fun SectionTitle(text: String) { + BasicText( + text = text.uppercase(), + style = + RecipeTheme.typography.label.copy( + color = RecipeTheme.colors.contentMuted, + fontSize = SectionHeaderTextSize, + letterSpacing = SectionHeaderTracking, + fontWeight = FontWeight.Bold, + ), + ) +} + +@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)) + ServingsStepper( + servings = servings, + onDecrement = { onServingsChange(servings - 1) }, + onIncrement = { onServingsChange(servings + 1) }, + ) + } +} + +@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)) { + Column(verticalArrangement = Arrangement.spacedBy(IngredientRowGap)) { + ingredients.forEach { slot -> + 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 ServingsStepper( + servings: Int, + onDecrement: () -> Unit, + onIncrement: () -> Unit, +) { + val colors = RecipeTheme.colors + Row( + modifier = + Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(colors.surface) + .padding(horizontal = RecipeTheme.spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs), + ) { + StepperButton( + icon = Lucide.Minus, + contentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y), + enabled = servings > MIN_RECIPE_SERVINGS, + onClick = onDecrement, + ) + BasicText( + text = servings.toString(), + style = + RecipeTheme.typography.body.copy( + color = colors.content, + fontWeight = FontWeight.SemiBold, + ), + modifier = Modifier.width(ServingsValueWidth), + ) + StepperButton( + icon = Lucide.Plus, + contentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y), + enabled = servings < MAX_RECIPE_SERVINGS, + onClick = onIncrement, + ) + } +} + +@Composable +private fun StepperButton( + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val colors = RecipeTheme.colors + UnstyledButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(StepperButtonSize), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + UnstyledIcon( + imageVector = icon, + contentDescription = contentDescription, + tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f), + modifier = Modifier.size(StepperIconSize), + ) + } + } +} + +@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.Medium, + fontSize = StepTextSize, + ), + 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), + ) + } +} + +@Composable +private fun PlanButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = RecipeTheme.colors + GlassSurface( + modifier = modifier.height(PlanButtonHeight), + cornerRadius = PlanButtonHeight / 2, + tint = colors.surfaceGlass, + ) { + UnstyledButton( + onClick = onClick, + backgroundColor = Color.Transparent, + contentColor = colors.content, + contentPadding = PaddingValues(horizontal = RecipeTheme.spacing.lg), + modifier = Modifier.fillMaxHeight(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs), + ) { + UnstyledIcon( + imageVector = Lucide.Calendar, + contentDescription = null, + tint = colors.content, + modifier = Modifier.size(PlanButtonIconSize), + ) + BasicText( + text = stringResource(Res.string.recipe_detail_plan_button), + style = + RecipeTheme.typography.label.copy( + color = colors.content, + fontWeight = FontWeight.SemiBold, + ), + ) + } + } + } +} + +private const val SHEET_HEIGHT_FRACTION = 0.92f +private const val ScrimFadeMillis = 250 + +private val ScrimColor = Color.Black.copy(alpha = 0.45f) +private val SheetCornerRadius = 28.dp +private val HeroHeight = 200.dp +private val HandleWidth = 36.dp +private val HandleHeight = 5.dp +private val IngredientRowGap = 6.dp +private val MetaIconSize = 14.dp +private val StepperButtonSize = 30.dp +private val StepperIconSize = 14.dp +private val ServingsValueWidth = 28.dp +private val StepNumberWidth = 20.dp +private val PlanButtonHeight = 36.dp +private val PlanButtonIconSize = 14.dp + +private val TitleTextSize = 24.sp +private val TitleLineHeight = 28.sp +private val SectionHeaderTextSize = 11.sp +private val SectionHeaderTracking = 1.sp +private val StepTextSize = 14.sp +private val StepLineHeight = 20.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 new file mode 100644 index 0000000..93447c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailState.kt @@ -0,0 +1,14 @@ +package dev.ulfrx.recipe.ui.screens.recipedetail + +internal const val MIN_RECIPE_SERVINGS = 1 +internal const val MAX_RECIPE_SERVINGS = 12 + +sealed interface RecipeDetailState { + data object Hidden : RecipeDetailState + + data class Ready( + val recipe: RecipeDetailUi, + 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 new file mode 100644 index 0000000..3884720 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt @@ -0,0 +1,13 @@ +package dev.ulfrx.recipe.ui.screens.recipedetail + +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, +) 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 new file mode 100644 index 0000000..b3af8b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModel.kt @@ -0,0 +1,47 @@ +package dev.ulfrx.recipe.ui.screens.recipedetail + +import androidx.lifecycle.ViewModel +import dev.ulfrx.recipe.ui.components.recipe.options +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class RecipeDetailViewModel : ViewModel() { + private val _state = MutableStateFlow(RecipeDetailState.Hidden) + 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) { + current.copy(servings = value.coerceIn(MIN_RECIPE_SERVINGS, MAX_RECIPE_SERVINGS)) + } else { + current + } + } + + fun selectSubstitution( + slotId: String, + optionId: String, + ) = _state.update { current -> + if (current !is RecipeDetailState.Ready) return@update current + val slot = current.recipe.ingredients.firstOrNull { it.id == slotId } ?: return@update current + if (slot.options.none { it.id == optionId }) return@update current + + val substitutions = + if (optionId == slot.default.id) { + current.substitutions - slotId + } else { + current.substitutions + (slotId to optionId) + } + current.copy(substitutions = substitutions) + } +} 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/SampleRecipeDetails.kt new file mode 100644 index 0000000..c7e286c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt @@ -0,0 +1,400 @@ +package dev.ulfrx.recipe.ui.screens.recipedetail + +import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi +import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi +import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi + +internal val sampleRecipeDetails: Map = + listOf( + RecipeDetailUi( + id = "rcp_nalesniki", + title = "Naleśniki z twarogiem", + cookingMinutes = 25, + nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42), + ingredients = + listOf( + slot("Mąka pszenna", 60.0, "g"), + slot("Mleko", 125.0, "ml"), + slot("Jajka", 1.0, "szt."), + slot( + "Twaróg półtłusty", + 100.0, + "g", + alt("Twaróg chudy", 100.0, "g"), + alt("Serek wiejski", 120.0, "g"), + ), + slot("Miód", 10.0, "g", alt("Syrop klonowy", 12.0, "g")), + slot("Olej do smażenia", 5.0, "ml"), + ), + steps = + listOf( + "Zmiksuj mąkę, mleko, jajko i szczyptę soli na gładkie ciasto. Odstaw na 10 minut.", + "Rozgrzej odrobinę oleju na patelni i smaż cienkie naleśniki z obu stron na złoto.", + "Twaróg rozetrzyj z miodem na gładką masę.", + "Nałóż masę twarogową na naleśniki, zwiń i podawaj.", + ), + ), + RecipeDetailUi( + id = "rcp_owsianka", + title = "Owsianka z owocami i orzechami", + cookingMinutes = 10, + nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38), + ingredients = + listOf( + slot("Płatki owsiane", 50.0, "g"), + slot( + "Mleko", + 200.0, + "ml", + alt("Napój owsiany", 200.0, "ml"), + alt("Napój migdałowy", 200.0, "ml"), + ), + slot("Banan", 0.5, "szt."), + slot( + "Borówki", + 40.0, + "g", + alt("Maliny", 40.0, "g"), + alt("Truskawki", 50.0, "g"), + ), + slot( + "Orzechy włoskie", + 15.0, + "g", + alt("Migdały", 15.0, "g"), + alt("Orzechy laskowe", 15.0, "g"), + ), + slot("Miód", 10.0, "g"), + ), + steps = + listOf( + "Płatki owsiane zalej mlekiem i gotuj na małym ogniu 4–5 minut, mieszając.", + "Przełóż owsiankę do miski.", + "Ułóż na wierzchu pokrojonego banana, borówki i posiekane orzechy.", + "Polej miodem i podawaj.", + ), + ), + RecipeDetailUi( + id = "rcp_spaghetti", + title = "Spaghetti bolognese", + cookingMinutes = 40, + nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65), + ingredients = + listOf( + slot("Makaron spaghetti", 100.0, "g"), + slot( + "Mięso mielone wołowe", + 120.0, + "g", + alt("Mięso mielone z indyka", 120.0, "g"), + alt("Soczewica czerwona", 60.0, "g"), + ), + slot("Passata pomidorowa", 150.0, "ml"), + slot("Cebula", 0.5, "szt."), + slot("Czosnek", 1.0, "ząbek"), + slot("Oliwa", 10.0, "ml"), + ), + steps = + listOf( + "Makaron ugotuj al dente w osolonej wodzie wg opakowania.", + "Na oliwie zeszklij posiekaną cebulę i czosnek, dodaj mięso i smaż do zrumienienia.", + "Wlej passatę, dopraw solą, pieprzem i ziołami. Duś 15 minut.", + "Wymieszaj sos z odsączonym makaronem i podawaj.", + ), + ), + RecipeDetailUi( + id = "rcp_pierogi", + title = "Pierogi ruskie", + cookingMinutes = 90, + nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68), + ingredients = + listOf( + slot("Mąka pszenna", 120.0, "g"), + slot("Woda", 60.0, "ml"), + slot("Ziemniaki", 150.0, "g"), + slot("Twaróg półtłusty", 80.0, "g"), + slot("Cebula", 0.5, "szt."), + slot("Masło", 10.0, "g"), + ), + steps = + listOf( + "Z mąki, ciepłej wody i szczypty soli zagnieć gładkie ciasto. Odstaw pod ściereczką.", + "Ziemniaki ugotuj i ugnieć z twarogiem. Dodaj zeszkloną cebulę, dopraw solą i pieprzem.", + "Rozwałkuj ciasto, wykrawaj krążki, nakładaj farsz i zlepiaj pierogi.", + "Gotuj partiami w osolonej wodzie 3–4 minuty od wypłynięcia.", + "Podawaj okraszone masłem i podsmażoną cebulą.", + ), + ), + RecipeDetailUi( + id = "rcp_kanapka_awokado", + title = "Kanapka z awokado i jajkiem", + cookingMinutes = 5, + nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16), + ingredients = + listOf( + slot("Pieczywo razowe", 1.0, "kromka"), + slot("Awokado", 0.5, "szt."), + slot("Jajko", 1.0, "szt."), + slot("Sok z cytryny", 5.0, "ml"), + slot("Szczypiorek", 5.0, "g"), + ), + steps = + listOf( + "Jajko ugotuj na twardo (ok. 9 minut), ostudź i obierz.", + "Awokado rozgnieć widelcem z sokiem z cytryny, solą i pieprzem.", + "Posmaruj kromkę pastą z awokado.", + "Ułóż plastry jajka i posyp szczypiorkiem.", + ), + ), + RecipeDetailUi( + id = "rcp_schabowy", + title = "Schabowy z ziemniakami", + cookingMinutes = 60, + nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62), + ingredients = + listOf( + slot("Schab", 150.0, "g"), + slot("Jajko", 1.0, "szt."), + slot("Bułka tarta", 40.0, "g"), + slot("Mąka pszenna", 20.0, "g"), + slot("Ziemniaki", 300.0, "g"), + slot("Olej do smażenia", 30.0, "ml"), + ), + steps = + listOf( + "Ziemniaki obierz i ugotuj w osolonej wodzie.", + "Schab rozbij na cienkie kotlety, dopraw solą i pieprzem.", + "Panieruj kolejno w mące, rozkłóconym jajku i bułce tartej.", + "Smaż na rozgrzanym oleju z obu stron na złoto.", + "Podawaj z ziemniakami i ulubioną surówką.", + ), + ), + RecipeDetailUi( + id = "rcp_salatka_grecka", + title = "Sałatka grecka", + cookingMinutes = 15, + nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12), + ingredients = + listOf( + slot("Pomidory", 150.0, "g"), + slot("Ogórek", 0.5, "szt."), + slot("Papryka czerwona", 0.5, "szt."), + slot("Ser feta", 60.0, "g", alt("Ser sałatkowy", 60.0, "g")), + slot("Oliwki czarne", 30.0, "g"), + slot("Oliwa", 15.0, "ml"), + ), + steps = + listOf( + "Pomidory, ogórka i paprykę pokrój w grubą kostkę.", + "Przełóż warzywa do miski, dodaj oliwki.", + "Pokrusz fetę na wierzch.", + "Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.", + ), + ), + RecipeDetailUi( + id = "rcp_pomidorowa", + title = "Zupa pomidorowa z ryżem", + cookingMinutes = 35, + nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39), + ingredients = + listOf( + slot("Passata pomidorowa", 200.0, "ml"), + slot("Bulion warzywny", 400.0, "ml"), + slot("Ryż", 40.0, "g"), + slot("Marchewka", 1.0, "szt."), + slot("Śmietana 18%", 20.0, "ml"), + ), + steps = + listOf( + "Ryż ugotuj osobno do miękkości.", + "W garnku zagotuj bulion ze startą marchewką, gotuj 10 minut.", + "Wlej passatę i gotuj kolejne 10 minut. Dopraw solą i cukrem.", + "Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.", + ), + ), + RecipeDetailUi( + id = "rcp_kurczak_curry", + title = "Kurczak curry z ryżem basmati", + cookingMinutes = 45, + nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70), + ingredients = + listOf( + slot("Pierś z kurczaka", 150.0, "g"), + slot("Ryż basmati", 80.0, "g"), + slot("Mleko kokosowe", 120.0, "ml", alt("Śmietanka 18%", 120.0, "ml")), + slot("Pasta curry", 20.0, "g"), + slot("Cebula", 0.5, "szt."), + slot("Olej", 10.0, "ml"), + ), + steps = + listOf( + "Ryż basmati ugotuj wg opakowania.", + "Kurczaka pokrój w kostkę i obsmaż na oleju z posiekaną cebulą.", + "Dodaj pastę curry, smaż minutę, wlej mleko kokosowe.", + "Duś 12–15 minut do zgęstnienia sosu. Dopraw solą.", + "Podawaj z ryżem basmati.", + ), + ), + RecipeDetailUi( + id = "rcp_jajecznica", + title = "Jajecznica na maśle ze szczypiorkiem", + cookingMinutes = 8, + nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3), + ingredients = + listOf( + slot("Jajka", 3.0, "szt."), + slot("Masło", 10.0, "g"), + slot("Szczypiorek", 10.0, "g"), + ), + steps = + listOf( + "Rozpuść masło na patelni na małym ogniu.", + "Wbij jajka i smaż, delikatnie mieszając, do ścięcia.", + "Dopraw solą i pieprzem.", + "Posyp posiekanym szczypiorkiem i podawaj.", + ), + ), + RecipeDetailUi( + id = "rcp_risotto", + title = "Risotto z grzybami leśnymi", + cookingMinutes = 35, + nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66), + ingredients = + listOf( + slot("Ryż arborio", 80.0, "g"), + slot("Grzyby leśne", 100.0, "g"), + slot("Bulion warzywny", 350.0, "ml"), + slot("Cebula", 0.5, "szt."), + slot("Parmezan", 20.0, "g"), + slot("Masło", 15.0, "g"), + ), + steps = + listOf( + "Na maśle zeszklij posiekaną cebulę, dodaj grzyby i podsmaż.", + "Wsyp ryż i smaż minutę, aż stanie się szklisty.", + "Dolewaj ciepły bulion po chochli, mieszając, aż ryż go wchłonie.", + "Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.", + ), + ), + RecipeDetailUi( + id = "rcp_tortilla", + title = "Tortilla z kurczakiem i warzywami", + cookingMinutes = 20, + nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48), + ingredients = + listOf( + slot("Tortilla pszenna", 1.0, "szt."), + slot("Pierś z kurczaka", 120.0, "g"), + slot("Papryka", 0.5, "szt."), + slot("Sałata", 30.0, "g"), + slot("Sos jogurtowy", 30.0, "g"), + slot("Olej", 5.0, "ml"), + ), + steps = + listOf( + "Kurczaka pokrój w paski, dopraw i obsmaż na oleju.", + "Paprykę pokrój w cienkie paski.", + "Tortillę podgrzej na suchej patelni.", + "Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.", + ), + ), + RecipeDetailUi( + id = "rcp_smoothie", + title = "Smoothie bananowo-szpinakowe", + cookingMinutes = 5, + nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33), + ingredients = + listOf( + slot("Banan", 1.0, "szt."), + slot("Szpinak świeży", 30.0, "g"), + slot( + "Jogurt naturalny", + 100.0, + "g", + alt("Skyr", 100.0, "g"), + alt("Kefir", 120.0, "g"), + ), + slot("Mleko", 100.0, "ml", alt("Napój owsiany", 100.0, "ml")), + ), + steps = + listOf( + "Wszystkie składniki umieść w blenderze.", + "Miksuj do uzyskania gładkiej konsystencji.", + "Przelej do szklanki i podawaj od razu.", + ), + ), + RecipeDetailUi( + id = "rcp_losos", + title = "Łosoś pieczony z brokułami", + cookingMinutes = 30, + nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12), + ingredients = + listOf( + slot("Filet z łososia", 150.0, "g"), + slot("Brokuł", 200.0, "g"), + slot("Oliwa", 15.0, "ml"), + slot("Cytryna", 0.5, "szt."), + slot("Czosnek", 1.0, "ząbek"), + ), + steps = + listOf( + "Piekarnik nagrzej do 200°C.", + "Łososia skrop oliwą i sokiem z cytryny, dopraw solą i pieprzem.", + "Brokuł podziel na różyczki, wymieszaj z oliwą i czosnkiem.", + "Piecz łososia i brokuły na blasze ok. 15–18 minut.", + ), + ), + RecipeDetailUi( + id = "rcp_nadziewane_papryki", + title = "Papryki nadziewane kaszą i warzywami", + cookingMinutes = 55, + nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58), + ingredients = + listOf( + slot("Papryka", 2.0, "szt."), + slot("Kasza jaglana", 60.0, "g"), + slot("Cukinia", 80.0, "g"), + slot("Passata pomidorowa", 100.0, "ml"), + slot("Cebula", 0.5, "szt."), + slot("Oliwa", 10.0, "ml"), + ), + steps = + listOf( + "Kaszę jaglaną ugotuj do miękkości.", + "Na oliwie podsmaż cebulę i pokrojoną cukinię.", + "Wymieszaj kaszę z warzywami i połową passaty. Dopraw.", + "Papryki przekrój, oczyść i napełnij farszem.", + "Polej resztą passaty i piecz w 190°C ok. 30 minut.", + ), + ), + ).associateBy { it.id } + +internal fun sampleRecipeDetail(id: String): RecipeDetailUi? = sampleRecipeDetails[id] + +private fun slot( + name: String, + amount: Double, + unit: String, + vararg alternatives: RecipeIngredientOptionUi, +) = RecipeIngredientSlotUi( + default = option(name, amount, unit), + alternatives = alternatives.toList(), + id = "sample-slot:$name:$amount:$unit", +) + +private fun option( + name: String, + amount: Double, + unit: String, +) = RecipeIngredientOptionUi( + id = "sample:$name", + name = name, + amount = amount, + unit = unit, +) + +private fun alt( + name: String, + amount: Double, + unit: String, +) = option(name, amount, unit) 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 468ec45..87bed4c 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 @@ -17,6 +17,8 @@ import com.composables.icons.lucide.Search import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel +import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet +import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res @@ -27,7 +29,9 @@ import recipe.composeapp.generated.resources.search_screen_empty_results_title fun SearchScreen( viewModel: ShellSearchViewModel, catalogViewModel: RecipeCatalogViewModel, + detailViewModel: RecipeDetailViewModel, catalogGridState: LazyGridState, + onPlanRecipe: (String) -> Unit = {}, ) { val state by viewModel.state.collectAsStateWithLifecycle() val catalogState by catalogViewModel.state.collectAsStateWithLifecycle() @@ -55,10 +59,15 @@ fun SearchScreen( } else { RecipeCatalogGrid( state = catalogState, - onRecipeClick = {}, + onRecipeClick = detailViewModel::open, gridState = catalogGridState, modifier = Modifier.fillMaxSize(), ) } + + RecipeDetailSheet( + viewModel = detailViewModel, + onPlanRecipe = onPlanRecipe, + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt index e2ff51a..c67f85f 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt @@ -3,6 +3,6 @@ package dev.ulfrx.recipe.ui.screens.search.catalog data class RecipeCardUi( val id: String, val title: String, - val minutes: Int, + val cookingMinutes: Int, val kcal: Int, ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt index 07a82bc..1ced103 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt @@ -166,7 +166,7 @@ private fun RecipeMetaRow(card: RecipeCardUi) { ) { MetaItem( icon = Lucide.Clock, - label = stringResource(Res.string.recipe_card_minutes_format, card.minutes), + label = stringResource(Res.string.recipe_card_minutes_format, card.cookingMinutes), ) MetaItem( icon = Lucide.Flame, diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt index 9feddea..a64048d 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt @@ -5,91 +5,91 @@ internal val sampleRecipeCatalogCards: List = RecipeCardUi( id = "rcp_nalesniki", title = "Naleśniki z twarogiem", - minutes = 25, + cookingMinutes = 25, kcal = 320, ), RecipeCardUi( id = "rcp_owsianka", title = "Owsianka z owocami i orzechami", - minutes = 10, + cookingMinutes = 10, kcal = 280, ), RecipeCardUi( id = "rcp_spaghetti", title = "Spaghetti bolognese", - minutes = 40, + cookingMinutes = 40, kcal = 540, ), RecipeCardUi( id = "rcp_pierogi", title = "Pierogi ruskie", - minutes = 90, + cookingMinutes = 90, kcal = 460, ), RecipeCardUi( id = "rcp_kanapka_awokado", title = "Kanapka z awokado i jajkiem", - minutes = 5, + cookingMinutes = 5, kcal = 210, ), RecipeCardUi( id = "rcp_schabowy", title = "Schabowy z ziemniakami", - minutes = 60, + cookingMinutes = 60, kcal = 720, ), RecipeCardUi( id = "rcp_salatka_grecka", title = "Sałatka grecka", - minutes = 15, + cookingMinutes = 15, kcal = 310, ), RecipeCardUi( id = "rcp_pomidorowa", title = "Zupa pomidorowa z ryżem", - minutes = 35, + cookingMinutes = 35, kcal = 240, ), RecipeCardUi( id = "rcp_kurczak_curry", title = "Kurczak curry z ryżem basmati", - minutes = 45, + cookingMinutes = 45, kcal = 580, ), RecipeCardUi( id = "rcp_jajecznica", title = "Jajecznica na maśle ze szczypiorkiem", - minutes = 8, + cookingMinutes = 8, kcal = 290, ), RecipeCardUi( id = "rcp_risotto", title = "Risotto z grzybami leśnymi", - minutes = 35, + cookingMinutes = 35, kcal = 470, ), RecipeCardUi( id = "rcp_tortilla", title = "Tortilla z kurczakiem i warzywami", - minutes = 20, + cookingMinutes = 20, kcal = 430, ), RecipeCardUi( id = "rcp_smoothie", title = "Smoothie bananowo-szpinakowe", - minutes = 5, + cookingMinutes = 5, kcal = 180, ), RecipeCardUi( id = "rcp_losos", title = "Łosoś pieczony z brokułami", - minutes = 30, + cookingMinutes = 30, kcal = 510, ), RecipeCardUi( id = "rcp_nadziewane_papryki", title = "Papryki nadziewane kaszą i warzywami", - minutes = 55, + cookingMinutes = 55, kcal = 390, ), ) 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 3af5c98..1ad1fe4 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 @@ -26,6 +26,7 @@ import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState 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.screens.recipedetail.RecipeDetailViewModel import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.koin.compose.viewmodel.koinViewModel @@ -35,6 +36,7 @@ fun AppShell(modifier: Modifier = Modifier) { val navigator = remember { TabNavigator() } val searchVm: ShellSearchViewModel = koinViewModel() val catalogVm: RecipeCatalogViewModel = koinViewModel() + val detailVm: RecipeDetailViewModel = koinViewModel() val catalogGridState = rememberLazyGridState() val searchState by searchVm.state.collectAsStateWithLifecycle() val backdropState = rememberGlassBackdropState() @@ -63,6 +65,7 @@ fun AppShell(modifier: Modifier = Modifier) { SearchScreen( viewModel = searchVm, catalogViewModel = catalogVm, + detailViewModel = detailVm, catalogGridState = catalogGridState, ) } else { diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt index 60ec7a1..2f61f75 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt @@ -17,12 +17,12 @@ data object RecipeGlass { val dockPress: RecipeGlassStyle = RecipeGlassStyle( - refraction = 0.20f, - curve = 0.05f, + refraction = 0.05f, + curve = 0.25f, edge = 0.04f, - dispersion = 0.03f, - saturation = 0.6f, - contrast = 1.8f, + dispersion = 0.0f, + saturation = 1.0f, + contrast = 1.0f, frost = 0.dp, ) } 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 new file mode 100644 index 0000000..fe88398 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailViewModelTest.kt @@ -0,0 +1,81 @@ +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 + } +}