Add recipe detail
This commit is contained in:
@@ -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<ShoppingViewModel>()
|
||||
viewModel<ShellSearchViewModel>()
|
||||
viewModel<RecipeCatalogViewModel>()
|
||||
viewModel<RecipeDetailViewModel>()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 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)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,91 +5,91 @@ internal val sampleRecipeCatalogCards: List<RecipeCardUi> =
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user