Redesign recipe detail screen

This commit is contained in:
2026-05-30 19:56:49 +02:00
parent 22b43050d6
commit 121f79109a
10 changed files with 396 additions and 206 deletions

View File

@@ -191,7 +191,9 @@ private fun Modifier.expandingHeight(
* that match an in-flight settle become no-ops — no flag, no race. * that match an in-flight settle become no-ops — no flag, no race.
*/ */
@Stable @Stable
private class PillExpansion(initial: Float) { private class PillExpansion(
initial: Float,
) {
var progress by mutableFloatStateOf(initial) var progress by mutableFloatStateOf(initial)
private set private set
var fullHeightPx by mutableIntStateOf(0) var fullHeightPx by mutableIntStateOf(0)
@@ -200,19 +202,27 @@ private class PillExpansion(initial: Float) {
private var target: Float = initial private var target: Float = initial
private var settleJob: Job? = null private var settleJob: Job? = null
fun dragBy(delta: Float, range: Float) { fun dragBy(
delta: Float,
range: Float,
) {
settleJob?.cancel() settleJob?.cancel()
progress = (progress - delta / range).coerceIn(0f, 1f) progress = (progress - delta / range).coerceIn(0f, 1f)
target = progress 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 if (this.target == target && settleJob?.isActive == true) return
this.target = target this.target = target
settleJob?.cancel() settleJob?.cancel()
settleJob = settleJob =
scope.launch { scope.launch {
Animatable(progress).also { it.updateBounds(0f, 1f) } Animatable(progress)
.also { it.updateBounds(0f, 1f) }
.animateTo( .animateTo(
targetValue = target, targetValue = target,
animationSpec = animationSpec =

View File

@@ -43,7 +43,6 @@ class HorizonCalendarHolder(
companion object { companion object {
private const val DEFAULT_HORIZON_DAYS = 7 private const val DEFAULT_HORIZON_DAYS = 7
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate = fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate = today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
} }
} }

View File

@@ -21,7 +21,8 @@ class OverlayDismisser {
} }
} }
val LocalOverlayDismisser = staticCompositionLocalOf<OverlayDismisser> { val LocalOverlayDismisser =
staticCompositionLocalOf<OverlayDismisser> {
error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.") error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.")
} }

View File

@@ -1,7 +1,6 @@
package dev.ulfrx.recipe.ui.components.recipe package dev.ulfrx.recipe.ui.components.recipe
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -69,14 +69,13 @@ fun IngredientRow(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(CornerRadius))
.background(colors.surface)
.animateContentSize(), .animateContentSize(),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = MinRowHeight)
.padding(horizontal = PaddingHorizontal, vertical = PaddingVertical), .padding(horizontal = PaddingHorizontal, vertical = PaddingVertical),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
@@ -86,7 +85,7 @@ fun IngredientRow(
style = style =
typography.body.copy( typography.body.copy(
color = colors.content, color = colors.content,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
fontSize = NameTextSize, fontSize = NameTextSize,
lineHeight = LineHeight, lineHeight = LineHeight,
), ),
@@ -137,7 +136,7 @@ private fun AmountLabel(
style = style =
typography.body.copy( typography.body.copy(
color = colors.content, color = colors.content,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
fontSize = NameTextSize, fontSize = NameTextSize,
lineHeight = LineHeight, lineHeight = LineHeight,
), ),
@@ -263,14 +262,14 @@ internal fun RecipeIngredientSlotUi.scaledBy(servings: Int) =
id = id, id = id,
) )
private val CornerRadius = 12.dp private val MinRowHeight = 48.dp
private val PaddingHorizontal = 12.dp private val PaddingHorizontal = 12.dp
private val PaddingVertical = 16.dp private val PaddingVertical = 12.dp
private val NameTextSize = 13.sp private val NameTextSize = 12.sp
private val UnitTextSize = 12.sp private val UnitTextSize = 11.sp
private val LineHeight = 18.sp private val LineHeight = 16.sp
private val ToggleSize = 28.dp private val ToggleSize = 24.dp
private val ToggleIconSize = 14.dp private val ToggleIconSize = 12.dp
private val OptionCornerRadius = 10.dp private val OptionCornerRadius = 10.dp
private val OptionPadding = 12.dp private val OptionPadding = 12.dp
private val OptionMetaGap = 2.dp private val OptionMetaGap = 2.dp

View File

@@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -45,6 +46,7 @@ fun NutritionSummary(
nutrition: RecipeNutritionUi, nutrition: RecipeNutritionUi,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = RecipeTheme.colors
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
@@ -53,21 +55,25 @@ fun NutritionSummary(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = nutrition.kcal.toString(), value = nutrition.kcal.toString(),
label = stringResource(Res.string.nutrition_macro_kcal), label = stringResource(Res.string.nutrition_macro_kcal),
valueColor = colors.content,
) )
MacroCard( MacroCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.protein), value = stringResource(Res.string.nutrition_grams_format, nutrition.protein),
label = stringResource(Res.string.nutrition_macro_protein), label = stringResource(Res.string.nutrition_macro_protein),
valueColor = colors.macroProtein,
) )
MacroCard( MacroCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.fat), value = stringResource(Res.string.nutrition_grams_format, nutrition.fat),
label = stringResource(Res.string.nutrition_macro_fat), label = stringResource(Res.string.nutrition_macro_fat),
valueColor = colors.macroFat,
) )
MacroCard( MacroCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs), value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs),
label = stringResource(Res.string.nutrition_macro_carbs), label = stringResource(Res.string.nutrition_macro_carbs),
valueColor = colors.macroCarbs,
) )
} }
} }
@@ -76,6 +82,7 @@ fun NutritionSummary(
private fun MacroCard( private fun MacroCard(
value: String, value: String,
label: String, label: String,
valueColor: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = RecipeTheme.colors val colors = RecipeTheme.colors
@@ -91,7 +98,7 @@ private fun MacroCard(
text = value, text = value,
style = style =
RecipeTheme.typography.body.copy( RecipeTheme.typography.body.copy(
color = colors.content, color = valueColor,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = ValueTextSize, fontSize = ValueTextSize,
), ),

View File

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

View File

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

View File

@@ -3,8 +3,8 @@ package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -32,10 +32,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight 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.SheetDetent
import com.composables.core.rememberModalBottomSheetState import com.composables.core.rememberModalBottomSheetState
import com.composables.icons.lucide.Calendar import com.composables.icons.lucide.Calendar
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Lucide 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.UnstyledButton
import com.composeunstyled.UnstyledIcon import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource 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.RecipeNutritionUi
import dev.ulfrx.recipe.ui.components.recipe.scaledBy import dev.ulfrx.recipe.ui.components.recipe.scaledBy
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.nutrition_label 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_handle_a11y
import recipe.composeapp.generated.resources.recipe_detail_plan_button 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_ingredients
import recipe.composeapp.generated.resources.recipe_detail_section_steps 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.recipe_detail_step_number_format
import recipe.composeapp.generated.resources.sample_recipe
@Composable @Composable
fun RecipeDetailSheet( fun RecipeDetailSheet(
@@ -93,16 +81,19 @@ fun RecipeDetailSheet(
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
) )
val ready = state as? RecipeDetailState.Ready val ready = state as? RecipeDetailState.Ready
val hasReadyRecipe = ready != null
LaunchedEffect(ready != null) { LaunchedEffect(hasReadyRecipe) {
sheetState.targetDetent = if (ready != null) SheetDetent.FullyExpanded else SheetDetent.Hidden 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 // 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 // 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. // 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) { LaunchedEffect(sheetState.isIdle, sheetState.currentDetent) {
if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && ready != null) { if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && hasReadyRecipe) {
viewModel.dismiss() viewModel.dismiss()
} }
} }
@@ -138,7 +129,6 @@ private fun BottomSheetScope.RecipeDetailContent(
onPlanRecipe: (String) -> Unit, onPlanRecipe: (String) -> Unit,
) { ) {
val colors = RecipeTheme.colors val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val spacing = RecipeTheme.spacing val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
@@ -153,28 +143,17 @@ private fun BottomSheetScope.RecipeDetailContent(
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) { Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) { GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { 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)) { 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)) Spacer(Modifier.height(spacing.xl))
NutritionSection(nutrition = detail.nutrition.scaledBy(servings)) NutritionSection(nutrition = detail.nutrition.scaledBy(servings))
Spacer(Modifier.height(spacing.xl))
ServingsSection(servings = servings, onServingsChange = onServingsChange)
Spacer(Modifier.height(spacing.xl)) Spacer(Modifier.height(spacing.xl))
IngredientsSection( IngredientsSection(
ingredients = detail.ingredients, ingredients = detail.ingredients,
@@ -207,61 +186,13 @@ private fun BottomSheetScope.RecipeDetailContent(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(top = spacing.lg, end = spacing.lg), .padding(top = spacing.xl, end = spacing.lg),
onClick = { onPlanRecipe(detail.id) }, 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 @Composable
private fun Section( private fun Section(
title: String, 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 @Composable
private fun IngredientsSection( private fun IngredientsSection(
ingredients: List<RecipeIngredientSlotUi>, ingredients: List<RecipeIngredientSlotUi>,
@@ -319,9 +231,19 @@ private fun IngredientsSection(
substitutions: Map<String, String>, substitutions: Map<String, String>,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit, 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)) { Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
Column(verticalArrangement = Arrangement.spacedBy(INGREDIENT_ROW_GAP)) { Column(
ingredients.forEach { slot -> 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( IngredientRow(
slot = slot.scaledBy(servings), slot = slot.scaledBy(servings),
selectedOptionId = substitutions[slot.id] ?: slot.default.id, 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 @Composable
private fun StepsSection(steps: List<String>) { private fun StepsSection(steps: List<String>) {
Section(title = stringResource(Res.string.recipe_detail_section_steps)) { 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 @Composable
private fun StepRow( private fun StepRow(
number: Int, number: Int,
@@ -424,8 +294,8 @@ private fun StepRow(
style = style =
RecipeTheme.typography.body.copy( RecipeTheme.typography.body.copy(
color = colors.contentMuted, color = colors.contentMuted,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontSize = STEP_TEXT_SIZE, fontSize = STEP_NUMBER_TEXT_SIZE,
), ),
modifier = Modifier.width(STEP_NUMBER_WIDTH), 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 SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
private val SHEET_CORNER_RADIUS = 28.dp private val SHEET_CORNER_RADIUS = 28.dp
private val HERO_HEIGHT = 200.dp
private val HANDLE_WIDTH = 36.dp private val HANDLE_WIDTH = 36.dp
private val HANDLE_HEIGHT = 5.dp private val HANDLE_HEIGHT = 5.dp
private val INGREDIENT_ROW_GAP = 6.dp private val INGREDIENTS_CARD_CORNER = 16.dp
private val META_ICON_SIZE = 14.dp private val INGREDIENT_DIVIDER_INSET = 12.dp
private val STEPPER_BUTTON_SIZE = 30.dp private val INGREDIENT_DIVIDER_THICKNESS = 1.dp
private val STEPPER_ICON_SIZE = 14.dp private val CARD_BORDER_WIDTH = 1.dp
private val SERVINGS_VALUE_WIDTH = 28.dp
private val STEP_NUMBER_WIDTH = 20.dp private val STEP_NUMBER_WIDTH = 20.dp
private val PLAN_BUTTON_HEIGHT = 36.dp private val PLAN_BUTTON_HEIGHT = 36.dp
private val PLAN_BUTTON_ICON_SIZE = 14.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_TEXT_SIZE = 11.sp
private val SECTION_HEADER_TRACKING = 1.sp private val SECTION_HEADER_TRACKING = 1.sp
private val STEP_TEXT_SIZE = 14.sp private val STEP_NUMBER_TEXT_SIZE = 11.sp
private val STEP_LINE_HEIGHT = 20.sp private val STEP_TEXT_SIZE = 13.sp
private val STEP_LINE_HEIGHT = 19.sp

View File

@@ -18,6 +18,9 @@ public data class RecipeColors(
val separator: Color, val separator: Color,
val borderCard: Color, val borderCard: Color,
val destructive: Color, val destructive: Color,
val macroProtein: Color,
val macroFat: Color,
val macroCarbs: Color,
) )
public val LightRecipeColors: RecipeColors = public val LightRecipeColors: RecipeColors =
@@ -33,6 +36,9 @@ public val LightRecipeColors: RecipeColors =
separator = Color(0xFFE5E1DA), separator = Color(0xFFE5E1DA),
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f), borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
destructive = Color(0xFFC0392B), destructive = Color(0xFFC0392B),
macroProtein = Color(0xFF3B82F6),
macroFat = Color(0xFFD97706),
macroCarbs = Color(0xFFEA580C),
) )
public val DarkRecipeColors: RecipeColors = public val DarkRecipeColors: RecipeColors =
@@ -48,4 +54,7 @@ public val DarkRecipeColors: RecipeColors =
separator = Color(0xFF383B40), separator = Color(0xFF383B40),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f), borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
destructive = Color(0xFFE57368), destructive = Color(0xFFE57368),
macroProtein = Color(0xFF60A5FA),
macroFat = Color(0xFFFBBF24),
macroCarbs = Color(0xFFFB923C),
) )

View File

@@ -31,14 +31,36 @@ data object RecipeGlass {
/** Calm refraction with strong frost — for large surfaces where [menu] would read as a murky lens. */ /** Calm refraction with strong frost — for large surfaces where [menu] would read as a murky lens. */
val panel: RecipeGlassStyle = val panel: RecipeGlassStyle =
RecipeGlassStyle( RecipeGlassStyle(
refraction = 0.03f, refraction = 0.10f,
curve = 0.25f, curve = 0.3f,
edge = 0.01f, edge = 0.01f,
dispersion = 0.0f, dispersion = 0.03f,
saturation = 0.5f, saturation = 0.5f,
contrast = 1.3f, contrast = 1.5f,
frost = 28.dp, 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( data class RecipeGlassStyle(