Redesign recipe detail screen
This commit is contained in:
@@ -191,7 +191,9 @@ private fun Modifier.expandingHeight(
|
||||
* that match an in-flight settle become no-ops — no flag, no race.
|
||||
*/
|
||||
@Stable
|
||||
private class PillExpansion(initial: Float) {
|
||||
private class PillExpansion(
|
||||
initial: Float,
|
||||
) {
|
||||
var progress by mutableFloatStateOf(initial)
|
||||
private set
|
||||
var fullHeightPx by mutableIntStateOf(0)
|
||||
@@ -200,19 +202,27 @@ private class PillExpansion(initial: Float) {
|
||||
private var target: Float = initial
|
||||
private var settleJob: Job? = null
|
||||
|
||||
fun dragBy(delta: Float, range: Float) {
|
||||
fun dragBy(
|
||||
delta: Float,
|
||||
range: Float,
|
||||
) {
|
||||
settleJob?.cancel()
|
||||
progress = (progress - delta / range).coerceIn(0f, 1f)
|
||||
target = progress
|
||||
}
|
||||
|
||||
fun animateTo(scope: CoroutineScope, target: Float, initialVelocity: Float = 0f) {
|
||||
fun animateTo(
|
||||
scope: CoroutineScope,
|
||||
target: Float,
|
||||
initialVelocity: Float = 0f,
|
||||
) {
|
||||
if (this.target == target && settleJob?.isActive == true) return
|
||||
this.target = target
|
||||
settleJob?.cancel()
|
||||
settleJob =
|
||||
scope.launch {
|
||||
Animatable(progress).also { it.updateBounds(0f, 1f) }
|
||||
Animatable(progress)
|
||||
.also { it.updateBounds(0f, 1f) }
|
||||
.animateTo(
|
||||
targetValue = target,
|
||||
animationSpec =
|
||||
|
||||
@@ -43,7 +43,6 @@ class HorizonCalendarHolder(
|
||||
companion object {
|
||||
private const val DEFAULT_HORIZON_DAYS = 7
|
||||
|
||||
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate =
|
||||
today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
|
||||
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate = today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ class OverlayDismisser {
|
||||
}
|
||||
}
|
||||
|
||||
val LocalOverlayDismisser = staticCompositionLocalOf<OverlayDismisser> {
|
||||
val LocalOverlayDismisser =
|
||||
staticCompositionLocalOf<OverlayDismisser> {
|
||||
error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegisterDismissibleOverlay(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -12,6 +11,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -69,14 +69,13 @@ fun IngredientRow(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(CornerRadius))
|
||||
.background(colors.surface)
|
||||
.animateContentSize(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = MinRowHeight)
|
||||
.padding(horizontal = PaddingHorizontal, vertical = PaddingVertical),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
@@ -86,7 +85,7 @@ fun IngredientRow(
|
||||
style =
|
||||
typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = NameTextSize,
|
||||
lineHeight = LineHeight,
|
||||
),
|
||||
@@ -137,7 +136,7 @@ private fun AmountLabel(
|
||||
style =
|
||||
typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = NameTextSize,
|
||||
lineHeight = LineHeight,
|
||||
),
|
||||
@@ -263,14 +262,14 @@ internal fun RecipeIngredientSlotUi.scaledBy(servings: Int) =
|
||||
id = id,
|
||||
)
|
||||
|
||||
private val CornerRadius = 12.dp
|
||||
private val MinRowHeight = 48.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 PaddingVertical = 12.dp
|
||||
private val NameTextSize = 12.sp
|
||||
private val UnitTextSize = 11.sp
|
||||
private val LineHeight = 16.sp
|
||||
private val ToggleSize = 24.dp
|
||||
private val ToggleIconSize = 12.dp
|
||||
private val OptionCornerRadius = 10.dp
|
||||
private val OptionPadding = 12.dp
|
||||
private val OptionMetaGap = 2.dp
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -45,6 +46,7 @@ fun NutritionSummary(
|
||||
nutrition: RecipeNutritionUi,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
@@ -53,21 +55,25 @@ fun NutritionSummary(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = nutrition.kcal.toString(),
|
||||
label = stringResource(Res.string.nutrition_macro_kcal),
|
||||
valueColor = colors.content,
|
||||
)
|
||||
MacroCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = stringResource(Res.string.nutrition_grams_format, nutrition.protein),
|
||||
label = stringResource(Res.string.nutrition_macro_protein),
|
||||
valueColor = colors.macroProtein,
|
||||
)
|
||||
MacroCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = stringResource(Res.string.nutrition_grams_format, nutrition.fat),
|
||||
label = stringResource(Res.string.nutrition_macro_fat),
|
||||
valueColor = colors.macroFat,
|
||||
)
|
||||
MacroCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs),
|
||||
label = stringResource(Res.string.nutrition_macro_carbs),
|
||||
valueColor = colors.macroCarbs,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +82,7 @@ fun NutritionSummary(
|
||||
private fun MacroCard(
|
||||
value: String,
|
||||
label: String,
|
||||
valueColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
@@ -91,7 +98,7 @@ private fun MacroCard(
|
||||
text = value,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
color = valueColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = ValueTextSize,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.GlassSurface
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
@Composable
|
||||
fun RecipeServingsStepper(
|
||||
servings: Int,
|
||||
servingsRange: IntRange,
|
||||
decrementContentDescription: String,
|
||||
incrementContentDescription: String,
|
||||
onServingsChange: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
GlassSurface(
|
||||
modifier = modifier.height(STEPPER_HEIGHT),
|
||||
cornerRadius = STEPPER_HEIGHT / 2,
|
||||
glassStyle = RecipeTheme.glass.chipOnGlass,
|
||||
tint = RecipeTheme.colors.surfaceGlass.copy(alpha = 0.45f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
|
||||
) {
|
||||
StepperButton(
|
||||
icon = Lucide.Minus,
|
||||
contentDescription = decrementContentDescription,
|
||||
enabled = servings > servingsRange.first,
|
||||
onClick = { onServingsChange(servings - 1) },
|
||||
)
|
||||
BasicText(
|
||||
text = servings.toString(),
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = SERVINGS_VALUE_TEXT_SIZE,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.width(SERVINGS_VALUE_WIDTH),
|
||||
)
|
||||
StepperButton(
|
||||
icon = Lucide.Plus,
|
||||
contentDescription = incrementContentDescription,
|
||||
enabled = servings < servingsRange.last,
|
||||
onClick = { onServingsChange(servings + 1) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepperButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.width(STEPPER_BUTTON_WIDTH).requiredHeight(STEPPER_TAP_TARGET_HEIGHT),
|
||||
) {
|
||||
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(STEPPER_ICON_SIZE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val STEPPER_HEIGHT = 28.dp
|
||||
private val STEPPER_TAP_TARGET_HEIGHT = 44.dp
|
||||
private val STEPPER_BUTTON_WIDTH = 28.dp
|
||||
private val STEPPER_ICON_SIZE = 12.dp
|
||||
private val SERVINGS_VALUE_WIDTH = 18.dp
|
||||
private val SERVINGS_VALUE_TEXT_SIZE = 12.sp
|
||||
@@ -0,0 +1,170 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
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.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.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Lucide
|
||||
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.RecipeServingsStepper
|
||||
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.recipe_card_minutes_format
|
||||
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.sample_recipe
|
||||
|
||||
@Composable
|
||||
internal fun RecipeDetailHero(
|
||||
title: String,
|
||||
cookingMinutes: Int,
|
||||
servings: Int,
|
||||
onServingsChange: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val typography = RecipeTheme.typography
|
||||
val spacing = RecipeTheme.spacing
|
||||
val heroBackdrop = rememberGlassBackdropState()
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth().height(HERO_HEIGHT)) {
|
||||
GlassBackdropSource(state = heroBackdrop, modifier = Modifier.fillMaxSize()) {
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.sample_recipe),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().background(colors.surfaceGlass),
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides heroBackdrop) {
|
||||
GlassSurface(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(HERO_BAND_INSET),
|
||||
cornerRadius = HERO_BAND_CORNER,
|
||||
glassStyle = RecipeTheme.glass.heroBand,
|
||||
recordAsSource = true,
|
||||
tint = RecipeTheme.colors.surfaceGlass.copy(alpha = 0.45f)
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
horizontal = HERO_BAND_PADDING_H,
|
||||
vertical = HERO_BAND_PADDING_V,
|
||||
),
|
||||
) {
|
||||
BasicText(
|
||||
text = title,
|
||||
style =
|
||||
typography.display.copy(
|
||||
color = colors.content,
|
||||
fontSize = TITLE_TEXT_SIZE,
|
||||
lineHeight = TITLE_LINE_HEIGHT,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(spacing.lg))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
MetaChip(
|
||||
text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
|
||||
icon = Lucide.Clock,
|
||||
)
|
||||
RecipeServingsStepper(
|
||||
servings = servings,
|
||||
servingsRange = MIN_RECIPE_SERVINGS..MAX_RECIPE_SERVINGS,
|
||||
decrementContentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
|
||||
incrementContentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
|
||||
onServingsChange = onServingsChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaChip(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
GlassSurface(
|
||||
modifier = modifier,
|
||||
cornerRadius = CHIP_CORNER_RADIUS,
|
||||
glassStyle = RecipeTheme.glass.chipOnGlass,
|
||||
tint = RecipeTheme.colors.surfaceGlass.copy(alpha = 0.45f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = CHIP_PADDING_H, vertical = CHIP_PADDING_V),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(CHIP_GAP),
|
||||
) {
|
||||
if (icon != null) {
|
||||
UnstyledIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colors.content,
|
||||
modifier = Modifier.size(CHIP_ICON_SIZE),
|
||||
)
|
||||
}
|
||||
BasicText(
|
||||
text = text,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = CHIP_TEXT_SIZE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val HERO_HEIGHT = 280.dp
|
||||
private val HERO_BAND_INSET = 16.dp
|
||||
private val HERO_BAND_CORNER = 22.dp
|
||||
private val HERO_BAND_PADDING_H = 16.dp
|
||||
private val HERO_BAND_PADDING_V = 14.dp
|
||||
|
||||
private val CHIP_CORNER_RADIUS = 14.dp
|
||||
private val CHIP_PADDING_H = 10.dp
|
||||
private val CHIP_PADDING_V = 7.dp
|
||||
private val CHIP_GAP = 5.dp
|
||||
private val CHIP_ICON_SIZE = 11.dp
|
||||
private val CHIP_TEXT_SIZE = 11.sp
|
||||
|
||||
private val TITLE_TEXT_SIZE = 17.sp
|
||||
private val TITLE_LINE_HEIGHT = 21.sp
|
||||
@@ -3,8 +3,8 @@ 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.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -32,10 +32,7 @@ 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
|
||||
@@ -50,10 +47,7 @@ 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
|
||||
@@ -66,20 +60,14 @@ 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(
|
||||
@@ -93,16 +81,19 @@ fun RecipeDetailSheet(
|
||||
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
|
||||
)
|
||||
val ready = state as? RecipeDetailState.Ready
|
||||
val hasReadyRecipe = ready != null
|
||||
|
||||
LaunchedEffect(ready != null) {
|
||||
sheetState.targetDetent = if (ready != null) SheetDetent.FullyExpanded else SheetDetent.Hidden
|
||||
LaunchedEffect(hasReadyRecipe) {
|
||||
sheetState.targetDetent = if (hasReadyRecipe) 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.
|
||||
// Keys are the sheet's settled state, NOT hasReadyRecipe — keying on the latter would fire
|
||||
// the effect at open time (before the sheet leaves Hidden) and immediately dismiss the recipe.
|
||||
LaunchedEffect(sheetState.isIdle, sheetState.currentDetent) {
|
||||
if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && ready != null) {
|
||||
if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && hasReadyRecipe) {
|
||||
viewModel.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -138,7 +129,6 @@ private fun BottomSheetScope.RecipeDetailContent(
|
||||
onPlanRecipe: (String) -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val typography = RecipeTheme.typography
|
||||
val spacing = RecipeTheme.spacing
|
||||
val scrollState = rememberScrollState()
|
||||
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
@@ -153,28 +143,17 @@ private fun BottomSheetScope.RecipeDetailContent(
|
||||
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) {
|
||||
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
RecipeHero()
|
||||
RecipeDetailHero(
|
||||
title = detail.title,
|
||||
cookingMinutes = detail.cookingMinutes,
|
||||
servings = servings,
|
||||
onServingsChange = onServingsChange,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
|
||||
BasicText(
|
||||
text = detail.title,
|
||||
style =
|
||||
typography.display.copy(
|
||||
color = colors.content,
|
||||
fontSize = TITLE_TEXT_SIZE,
|
||||
lineHeight = TITLE_LINE_HEIGHT,
|
||||
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,
|
||||
@@ -207,61 +186,13 @@ private fun BottomSheetScope.RecipeDetailContent(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = spacing.lg, end = spacing.lg),
|
||||
.padding(top = spacing.xl, end = spacing.lg),
|
||||
onClick = { onPlanRecipe(detail.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipeHero() {
|
||||
val colors = RecipeTheme.colors
|
||||
Box(modifier = Modifier.fillMaxWidth().height(HERO_HEIGHT)) {
|
||||
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(META_ICON_SIZE),
|
||||
)
|
||||
BasicText(
|
||||
text = stringResource(Res.string.recipe_card_minutes_format, minutes),
|
||||
style = RecipeTheme.typography.label.copy(color = colors.contentMuted),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(
|
||||
title: String,
|
||||
@@ -293,25 +224,6 @@ private fun NutritionSection(nutrition: RecipeNutritionUi) {
|
||||
}
|
||||
}
|
||||
|
||||
@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>,
|
||||
@@ -319,9 +231,19 @@ private fun IngredientsSection(
|
||||
substitutions: Map<String, String>,
|
||||
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val cardShape = RoundedCornerShape(INGREDIENTS_CARD_CORNER)
|
||||
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(INGREDIENT_ROW_GAP)) {
|
||||
ingredients.forEach { slot ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(cardShape)
|
||||
.background(colors.surface)
|
||||
.border(width = CARD_BORDER_WIDTH, color = colors.borderCard, shape = cardShape),
|
||||
) {
|
||||
ingredients.forEachIndexed { index, slot ->
|
||||
if (index > 0) IngredientDivider()
|
||||
IngredientRow(
|
||||
slot = slot.scaledBy(servings),
|
||||
selectedOptionId = substitutions[slot.id] ?: slot.default.id,
|
||||
@@ -337,6 +259,18 @@ private fun IngredientsSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IngredientDivider() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = INGREDIENT_DIVIDER_INSET)
|
||||
.height(INGREDIENT_DIVIDER_THICKNESS)
|
||||
.background(RecipeTheme.colors.separator),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepsSection(steps: List<String>) {
|
||||
Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
|
||||
@@ -348,70 +282,6 @@ private fun StepsSection(steps: List<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
@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(SERVINGS_VALUE_WIDTH),
|
||||
)
|
||||
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(STEPPER_BUTTON_SIZE),
|
||||
) {
|
||||
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(STEPPER_ICON_SIZE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepRow(
|
||||
number: Int,
|
||||
@@ -424,8 +294,8 @@ private fun StepRow(
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.contentMuted,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = STEP_TEXT_SIZE,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = STEP_NUMBER_TEXT_SIZE,
|
||||
),
|
||||
modifier = Modifier.width(STEP_NUMBER_WIDTH),
|
||||
)
|
||||
@@ -489,21 +359,18 @@ private const val SCRIM_FADE_MILLIS = 250
|
||||
|
||||
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
|
||||
private val SHEET_CORNER_RADIUS = 28.dp
|
||||
private val HERO_HEIGHT = 200.dp
|
||||
private val HANDLE_WIDTH = 36.dp
|
||||
private val HANDLE_HEIGHT = 5.dp
|
||||
private val INGREDIENT_ROW_GAP = 6.dp
|
||||
private val META_ICON_SIZE = 14.dp
|
||||
private val STEPPER_BUTTON_SIZE = 30.dp
|
||||
private val STEPPER_ICON_SIZE = 14.dp
|
||||
private val SERVINGS_VALUE_WIDTH = 28.dp
|
||||
private val INGREDIENTS_CARD_CORNER = 16.dp
|
||||
private val INGREDIENT_DIVIDER_INSET = 12.dp
|
||||
private val INGREDIENT_DIVIDER_THICKNESS = 1.dp
|
||||
private val CARD_BORDER_WIDTH = 1.dp
|
||||
private val STEP_NUMBER_WIDTH = 20.dp
|
||||
private val PLAN_BUTTON_HEIGHT = 36.dp
|
||||
private val PLAN_BUTTON_ICON_SIZE = 14.dp
|
||||
|
||||
private val TITLE_TEXT_SIZE = 24.sp
|
||||
private val TITLE_LINE_HEIGHT = 28.sp
|
||||
private val SECTION_HEADER_TEXT_SIZE = 11.sp
|
||||
private val SECTION_HEADER_TRACKING = 1.sp
|
||||
private val STEP_TEXT_SIZE = 14.sp
|
||||
private val STEP_LINE_HEIGHT = 20.sp
|
||||
private val STEP_NUMBER_TEXT_SIZE = 11.sp
|
||||
private val STEP_TEXT_SIZE = 13.sp
|
||||
private val STEP_LINE_HEIGHT = 19.sp
|
||||
|
||||
@@ -18,6 +18,9 @@ public data class RecipeColors(
|
||||
val separator: Color,
|
||||
val borderCard: Color,
|
||||
val destructive: Color,
|
||||
val macroProtein: Color,
|
||||
val macroFat: Color,
|
||||
val macroCarbs: Color,
|
||||
)
|
||||
|
||||
public val LightRecipeColors: RecipeColors =
|
||||
@@ -33,6 +36,9 @@ public val LightRecipeColors: RecipeColors =
|
||||
separator = Color(0xFFE5E1DA),
|
||||
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
|
||||
destructive = Color(0xFFC0392B),
|
||||
macroProtein = Color(0xFF3B82F6),
|
||||
macroFat = Color(0xFFD97706),
|
||||
macroCarbs = Color(0xFFEA580C),
|
||||
)
|
||||
|
||||
public val DarkRecipeColors: RecipeColors =
|
||||
@@ -48,4 +54,7 @@ public val DarkRecipeColors: RecipeColors =
|
||||
separator = Color(0xFF383B40),
|
||||
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
||||
destructive = Color(0xFFE57368),
|
||||
macroProtein = Color(0xFF60A5FA),
|
||||
macroFat = Color(0xFFFBBF24),
|
||||
macroCarbs = Color(0xFFFB923C),
|
||||
)
|
||||
|
||||
@@ -31,14 +31,36 @@ data object RecipeGlass {
|
||||
/** Calm refraction with strong frost — for large surfaces where [menu] would read as a murky lens. */
|
||||
val panel: RecipeGlassStyle =
|
||||
RecipeGlassStyle(
|
||||
refraction = 0.03f,
|
||||
curve = 0.25f,
|
||||
refraction = 0.10f,
|
||||
curve = 0.3f,
|
||||
edge = 0.01f,
|
||||
dispersion = 0.0f,
|
||||
dispersion = 0.03f,
|
||||
saturation = 0.5f,
|
||||
contrast = 1.3f,
|
||||
contrast = 1.5f,
|
||||
frost = 28.dp,
|
||||
)
|
||||
|
||||
val heroBand: RecipeGlassStyle =
|
||||
RecipeGlassStyle(
|
||||
refraction = 0.05f,
|
||||
curve = 0.20f,
|
||||
edge = 0f,
|
||||
dispersion = 0.03f,
|
||||
saturation = 0.5f,
|
||||
contrast = 1.5f,
|
||||
frost = 5.dp,
|
||||
)
|
||||
|
||||
val chipOnGlass: RecipeGlassStyle =
|
||||
RecipeGlassStyle(
|
||||
refraction = 0f,
|
||||
curve = 0f,
|
||||
edge = 0.1f,
|
||||
dispersion = 0.03f,
|
||||
saturation = 0.5f,
|
||||
contrast = 1.5f,
|
||||
frost = 5.dp,
|
||||
)
|
||||
}
|
||||
|
||||
data class RecipeGlassStyle(
|
||||
|
||||
Reference in New Issue
Block a user