Add recipe detail

This commit is contained in:
2026-05-26 22:39:35 +02:00
parent 6d38b8b775
commit c017a8e777
19 changed files with 1528 additions and 28 deletions

View File

@@ -74,14 +74,16 @@ dev.ulfrx.recipe/
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab) ├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
├── ui/ ├── ui/
│ ├── theme/ # Colors, typography, Liquid glass style tokens │ ├── theme/ # Colors, typography, Liquid glass style tokens
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful │ ├── components/ # Shared, stateless (VM-free) Recipe-styled composables built on Compose Unstyled where useful
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel │ └── screens/{recipes,planner,pantry,shopping,recipedetail}/ # Each with screen + ViewModel
├── data/{local,remote,repository}/ ├── data/{local,remote,repository}/
└── domain/ # Client-only logic; shared/ handles cross-cutting └── domain/ # Client-only logic; shared/ handles cross-cutting
``` ```
**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above. **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 ## 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. 1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor.

View File

@@ -108,6 +108,9 @@ kotlin {
// ASWebAuthenticationSession integration directly from Kotlin. // ASWebAuthenticationSession integration directly from Kotlin.
implementation(libs.ktor.clientDarwin) implementation(libs.ktor.clientDarwin)
} }
commonTest.dependencies {
implementation(libs.kotlin.test)
}
} }
} }

View File

@@ -30,6 +30,27 @@
<string name="recipe_card_minutes_format">%1$d min</string> <string name="recipe_card_minutes_format">%1$d min</string>
<string name="recipe_card_kcal_format">%1$d kcal</string> <string name="recipe_card_kcal_format">%1$d kcal</string>
<!-- Phase 2.1 — Nutrition facts widget (reusable across recipe detail, planner, …) -->
<string name="nutrition_label">Wartości odżywcze</string>
<string name="nutrition_macro_kcal">kcal</string>
<string name="nutrition_macro_protein">białko</string>
<string name="nutrition_macro_fat">tłuszcz</string>
<string name="nutrition_macro_carbs">węglowodany</string>
<string name="nutrition_grams_format">%1$dg</string>
<!-- Phase 2.1 — Ingredient row widget (reusable across recipe detail, planner, …) -->
<string name="ingredient_substitute_a11y">Zamień składnik</string>
<!-- Phase 2.1 — Recipe detail sheet (UI-only view; planner wiring lands in later phases) -->
<string name="recipe_detail_plan_button">Zaplanuj</string>
<string name="recipe_detail_servings_label">Porcje</string>
<string name="recipe_detail_section_ingredients">Składniki</string>
<string name="recipe_detail_section_steps">Kroki</string>
<string name="recipe_detail_step_number_format">%1$d.</string>
<string name="recipe_detail_servings_decrement_a11y">Zmniejsz liczbę porcji</string>
<string name="recipe_detail_servings_increment_a11y">Zwiększ liczbę porcji</string>
<string name="recipe_detail_handle_a11y">Przeciągnij, aby zamknąć szczegóły przepisu</string>
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) --> <!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string> <string name="search_open_a11y">Otwórz wyszukiwanie</string>
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string> <string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>

View File

@@ -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.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel import org.koin.plugin.module.dsl.viewModel
@@ -17,4 +18,5 @@ val shellModule =
viewModel<ShoppingViewModel>() viewModel<ShoppingViewModel>()
viewModel<ShellSearchViewModel>() viewModel<ShellSearchViewModel>()
viewModel<RecipeCatalogViewModel>() viewModel<RecipeCatalogViewModel>()
viewModel<RecipeDetailViewModel>()
} }

View File

@@ -45,9 +45,8 @@ import org.koin.compose.viewmodel.koinViewModel
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour * during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
* (`saveState=true`/`restoreState=true`). * (`saveState=true`/`restoreState=true`).
* *
* Phase 5+ introduces detail screens with their own VM scopes; at that point * Recipe detail is a modal bottom sheet (not a nav destination), so it needs no
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries * per-entry VM scope here; its VM is hosted by the surface that opens it.
* specifically (passed via `entryDecorators = listOf(...)`).
* *
* ## Search note * ## Search note
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not * Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not

View File

@@ -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<RecipeIngredientOptionUi> = 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<RecipeIngredientOptionUi>
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

View File

@@ -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

View File

@@ -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<RecipeIngredientSlotUi>,
servings: Int,
substitutions: Map<String, String>,
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<String>) {
Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
Column(verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
steps.forEachIndexed { index, step ->
StepRow(number = index + 1, text = step)
}
}
}
}
@Composable
private fun 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

View File

@@ -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<String, String> = emptyMap(),
) : RecipeDetailState
}

View File

@@ -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<RecipeIngredientSlotUi>,
val steps: List<String>,
)

View File

@@ -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>(RecipeDetailState.Hidden)
val state: StateFlow<RecipeDetailState> = _state.asStateFlow()
fun open(recipeId: String) {
_state.value = sampleRecipeDetail(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.Hidden
}
fun dismiss() {
_state.value = RecipeDetailState.Hidden
}
fun setServings(value: Int) =
_state.update { current ->
if (current is RecipeDetailState.Ready) {
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)
}
}

View File

@@ -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<String, RecipeDetailUi> =
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 45 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 34 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ś 1215 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. 1518 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)

View File

@@ -17,6 +17,8 @@ import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.empty.EmptyState 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.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel 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 dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
@@ -27,7 +29,9 @@ import recipe.composeapp.generated.resources.search_screen_empty_results_title
fun SearchScreen( fun SearchScreen(
viewModel: ShellSearchViewModel, viewModel: ShellSearchViewModel,
catalogViewModel: RecipeCatalogViewModel, catalogViewModel: RecipeCatalogViewModel,
detailViewModel: RecipeDetailViewModel,
catalogGridState: LazyGridState, catalogGridState: LazyGridState,
onPlanRecipe: (String) -> Unit = {},
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle() val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
@@ -55,10 +59,15 @@ fun SearchScreen(
} else { } else {
RecipeCatalogGrid( RecipeCatalogGrid(
state = catalogState, state = catalogState,
onRecipeClick = {}, onRecipeClick = detailViewModel::open,
gridState = catalogGridState, gridState = catalogGridState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
RecipeDetailSheet(
viewModel = detailViewModel,
onPlanRecipe = onPlanRecipe,
)
} }
} }

View File

@@ -3,6 +3,6 @@ package dev.ulfrx.recipe.ui.screens.search.catalog
data class RecipeCardUi( data class RecipeCardUi(
val id: String, val id: String,
val title: String, val title: String,
val minutes: Int, val cookingMinutes: Int,
val kcal: Int, val kcal: Int,
) )

View File

@@ -166,7 +166,7 @@ private fun RecipeMetaRow(card: RecipeCardUi) {
) { ) {
MetaItem( MetaItem(
icon = Lucide.Clock, icon = Lucide.Clock,
label = stringResource(Res.string.recipe_card_minutes_format, card.minutes), label = stringResource(Res.string.recipe_card_minutes_format, card.cookingMinutes),
) )
MetaItem( MetaItem(
icon = Lucide.Flame, icon = Lucide.Flame,

View File

@@ -5,91 +5,91 @@ internal val sampleRecipeCatalogCards: List<RecipeCardUi> =
RecipeCardUi( RecipeCardUi(
id = "rcp_nalesniki", id = "rcp_nalesniki",
title = "Naleśniki z twarogiem", title = "Naleśniki z twarogiem",
minutes = 25, cookingMinutes = 25,
kcal = 320, kcal = 320,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_owsianka", id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami", title = "Owsianka z owocami i orzechami",
minutes = 10, cookingMinutes = 10,
kcal = 280, kcal = 280,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_spaghetti", id = "rcp_spaghetti",
title = "Spaghetti bolognese", title = "Spaghetti bolognese",
minutes = 40, cookingMinutes = 40,
kcal = 540, kcal = 540,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_pierogi", id = "rcp_pierogi",
title = "Pierogi ruskie", title = "Pierogi ruskie",
minutes = 90, cookingMinutes = 90,
kcal = 460, kcal = 460,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_kanapka_awokado", id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem", title = "Kanapka z awokado i jajkiem",
minutes = 5, cookingMinutes = 5,
kcal = 210, kcal = 210,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_schabowy", id = "rcp_schabowy",
title = "Schabowy z ziemniakami", title = "Schabowy z ziemniakami",
minutes = 60, cookingMinutes = 60,
kcal = 720, kcal = 720,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_salatka_grecka", id = "rcp_salatka_grecka",
title = "Sałatka grecka", title = "Sałatka grecka",
minutes = 15, cookingMinutes = 15,
kcal = 310, kcal = 310,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_pomidorowa", id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem", title = "Zupa pomidorowa z ryżem",
minutes = 35, cookingMinutes = 35,
kcal = 240, kcal = 240,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_kurczak_curry", id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati", title = "Kurczak curry z ryżem basmati",
minutes = 45, cookingMinutes = 45,
kcal = 580, kcal = 580,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_jajecznica", id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem", title = "Jajecznica na maśle ze szczypiorkiem",
minutes = 8, cookingMinutes = 8,
kcal = 290, kcal = 290,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_risotto", id = "rcp_risotto",
title = "Risotto z grzybami leśnymi", title = "Risotto z grzybami leśnymi",
minutes = 35, cookingMinutes = 35,
kcal = 470, kcal = 470,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_tortilla", id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami", title = "Tortilla z kurczakiem i warzywami",
minutes = 20, cookingMinutes = 20,
kcal = 430, kcal = 430,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_smoothie", id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe", title = "Smoothie bananowo-szpinakowe",
minutes = 5, cookingMinutes = 5,
kcal = 180, kcal = 180,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_losos", id = "rcp_losos",
title = "Łosoś pieczony z brokułami", title = "Łosoś pieczony z brokułami",
minutes = 30, cookingMinutes = 30,
kcal = 510, kcal = 510,
), ),
RecipeCardUi( RecipeCardUi(
id = "rcp_nadziewane_papryki", id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami", title = "Papryki nadziewane kaszą i warzywami",
minutes = 55, cookingMinutes = 55,
kcal = 390, kcal = 390,
), ),
) )

View File

@@ -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.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -35,6 +36,7 @@ fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() } val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel() val searchVm: ShellSearchViewModel = koinViewModel()
val catalogVm: RecipeCatalogViewModel = koinViewModel() val catalogVm: RecipeCatalogViewModel = koinViewModel()
val detailVm: RecipeDetailViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState() val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle() val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState() val backdropState = rememberGlassBackdropState()
@@ -63,6 +65,7 @@ fun AppShell(modifier: Modifier = Modifier) {
SearchScreen( SearchScreen(
viewModel = searchVm, viewModel = searchVm,
catalogViewModel = catalogVm, catalogViewModel = catalogVm,
detailViewModel = detailVm,
catalogGridState = catalogGridState, catalogGridState = catalogGridState,
) )
} else { } else {

View File

@@ -17,12 +17,12 @@ data object RecipeGlass {
val dockPress: RecipeGlassStyle = val dockPress: RecipeGlassStyle =
RecipeGlassStyle( RecipeGlassStyle(
refraction = 0.20f, refraction = 0.05f,
curve = 0.05f, curve = 0.25f,
edge = 0.04f, edge = 0.04f,
dispersion = 0.03f, dispersion = 0.0f,
saturation = 0.6f, saturation = 1.0f,
contrast = 1.8f, contrast = 1.0f,
frost = 0.dp, frost = 0.dp,
) )
} }

View File

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