diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt index f7d730a..b89c621 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt @@ -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 = diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt index f4a05b6..c66a418 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt @@ -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)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt index 84c6946..60abc09 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt @@ -21,9 +21,10 @@ class OverlayDismisser { } } -val LocalOverlayDismisser = staticCompositionLocalOf { - error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.") -} +val LocalOverlayDismisser = + staticCompositionLocalOf { + error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.") + } @Composable fun RegisterDismissibleOverlay( diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt index e2e3e3f..cd27bd8 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt index 293847b..4b09c11 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/NutritionSummary.kt @@ -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, ), diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeServingsStepper.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeServingsStepper.kt new file mode 100644 index 0000000..11b7825 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeServingsStepper.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailHero.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailHero.kt new file mode 100644 index 0000000..be31998 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailHero.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt index 5bf79fa..c353b87 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt @@ -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, @@ -319,9 +231,19 @@ private fun IngredientsSection( substitutions: Map, 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) { Section(title = stringResource(Res.string.recipe_detail_section_steps)) { @@ -348,70 +282,6 @@ private fun StepsSection(steps: List) { } } -@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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt index fc8b347..c996130 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt @@ -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), ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt index a936afc..85bf082 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt @@ -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(