Restyle recipe details top bar

This commit is contained in:
2026-06-07 09:20:56 +02:00
parent ade14e28fc
commit f3900113a4
8 changed files with 191 additions and 93 deletions

View File

@@ -50,6 +50,7 @@
<string name="recipe_detail_servings_increment_a11y">Zwiększ liczbę porcji</string> <string name="recipe_detail_servings_increment_a11y">Zwiększ liczbę porcji</string>
<string name="sheet_drag_handle_a11y">Przeciągnij w dół, aby zamknąć</string> <string name="sheet_drag_handle_a11y">Przeciągnij w dół, aby zamknąć</string>
<string name="recipe_detail_not_found">Nie znaleziono przepisu</string> <string name="recipe_detail_not_found">Nie znaleziono przepisu</string>
<string name="recipe_detail_close_a11y">Zamknij szczegóły przepisu</string>
<string name="meal_plan_editor_not_found">Nie udało się otworzyć edytora</string> <string name="meal_plan_editor_not_found">Nie udało się otworzyć edytora</string>
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) --> <!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->

View File

@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
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
@@ -47,7 +49,14 @@ fun <T : NavKey> RecipeBottomSheet(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
entries: EntryProviderScope<T>.() -> Unit, entries: EntryProviderScope<T>.() -> Unit,
) { ) {
val modalSheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.Hidden, detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded)) val dismissalGate = remember { SheetDismissalGate() }
val modalSheetState = rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
confirmDetentChange = { detent ->
detent != SheetDetent.Hidden || !dismissalGate.isBlocked
},
)
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<T>() val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<T>()
val viewModelDecorator = rememberViewModelStoreNavEntryDecorator<T>() val viewModelDecorator = rememberViewModelStoreNavEntryDecorator<T>()
@@ -62,20 +71,23 @@ fun <T : NavKey> RecipeBottomSheet(
) )
Sheet( Sheet(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
enabled = !dismissalGate.isBlocked,
backgroundColor = RecipeTheme.colors.background, backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS), shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
) { ) {
SheetBody { CompositionLocalProvider(LocalSheetDismissalGate provides dismissalGate) {
if (state.backStack.isNotEmpty()) { SheetBody {
NavDisplay( if (state.backStack.isNotEmpty()) {
backStack = state.backStack, NavDisplay(
modifier = Modifier.fillMaxSize(), backStack = state.backStack,
onBack = { state.pop() }, modifier = Modifier.fillMaxSize(),
entryDecorators = listOf(saveableDecorator, viewModelDecorator), onBack = { state.pop() },
entryProvider = entryProvider(builder = entries), entryDecorators = listOf(saveableDecorator, viewModelDecorator),
) entryProvider = entryProvider(builder = entries),
} else { )
Box(modifier = Modifier.fillMaxSize()) } else {
Box(modifier = Modifier.fillMaxSize())
}
} }
} }
} }

View File

@@ -0,0 +1,46 @@
package dev.ulfrx.recipe.ui.components.sheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Counter-based gate that disables user-initiated sheet dismissal (drag,
* scrim tap) while at least one consumer holds a lock. Programmatic close via
* [RecipeBottomSheetState.dismiss] always succeeds.
*/
@Stable
class SheetDismissalGate {
private var lockCount by mutableStateOf(0)
val isBlocked: Boolean by derivedStateOf { lockCount > 0 }
internal fun acquire() {
lockCount++
}
internal fun release() {
if (lockCount > 0) lockCount--
}
}
internal val LocalSheetDismissalGate = compositionLocalOf<SheetDismissalGate?> { null }
/**
* Blocks gesture-initiated dismissal of the enclosing [RecipeBottomSheet]
* while this composable is in composition. Programmatic close via
* [RecipeBottomSheetState.dismiss] still works.
*/
@Composable
fun BlockSheetDismiss() {
val gate = LocalSheetDismissalGate.current ?: return
DisposableEffect(gate) {
gate.acquire()
onDispose { gate.release() }
}
}

View File

@@ -22,6 +22,7 @@ import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.sheet.BlockSheetDismiss
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
@@ -48,6 +49,7 @@ internal fun MealPlanEditorScreen(
) { ) {
when (val s = state) { when (val s = state) {
is MealPlanEditorState.Editing -> { is MealPlanEditorState.Editing -> {
BlockSheetDismiss()
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) { GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
MealPlanEditorContent( MealPlanEditorContent(
editing = s, editing = s,

View File

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe.ui.screens.recipedetail package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -19,6 +20,9 @@ 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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.ulfrx.recipe.ui.components.recipe.IngredientCard import dev.ulfrx.recipe.ui.components.recipe.IngredientCard
@@ -45,9 +49,10 @@ import recipe.composeapp.generated.resources.recipe_detail_step_number_format
@Composable @Composable
internal fun RecipeDetailContent( internal fun RecipeDetailContent(
ready: RecipeDetailState.Ready, ready: RecipeDetailState.Ready,
onPlanClick: () -> Unit,
onServingsChange: (Int) -> Unit, onServingsChange: (Int) -> Unit,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit, onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
topChromeInset: Dp,
topChromeHeight: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val spacing = RecipeTheme.spacing val spacing = RecipeTheme.spacing
@@ -58,11 +63,35 @@ internal fun RecipeDetailContent(
val servings = ready.servings val servings = ready.servings
Column(modifier = modifier.fillMaxSize().verticalScroll(scrollState)) { Column(modifier = modifier.fillMaxSize().verticalScroll(scrollState)) {
RecipeDetailHero( Spacer(Modifier.height(topChromeInset))
title = detail.title, // Aligns the title row with the floating close chrome at scroll=0:
cookingMinutes = detail.cookingMinutes, // same height, padded inside the chrome's circle pill.
onPlanClick = onPlanClick, Box(
) modifier =
Modifier
.fillMaxWidth()
.height(topChromeHeight)
.padding(horizontal = spacing.lg + topChromeHeight + spacing.sm),
contentAlignment = Alignment.Center,
) {
BasicText(
text = detail.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style =
RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.content,
fontWeight = FontWeight.Medium,
fontSize = TitleSize,
lineHeight = TitleLineHeight,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(spacing.xl))
RecipeDetailHero(cookingMinutes = detail.cookingMinutes)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
Spacer(Modifier.height(spacing.xl)) Spacer(Modifier.height(spacing.xl))
@@ -183,6 +212,9 @@ private fun StepRow(
} }
} }
private val TitleSize = 16.sp
private val TitleLineHeight = 17.sp
private val StepNumberWidth = 20.dp private val StepNumberWidth = 20.dp
private val StepNumberTextSize = 11.sp private val StepNumberTextSize = 11.sp
private val StepTextSize = 13.sp private val StepTextSize = 13.sp

View File

@@ -22,32 +22,22 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
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.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Calendar
import com.composables.icons.lucide.Clock import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledIcon import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.button.CircleButton
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.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.meal_plan_editor_title_a11y
import recipe.composeapp.generated.resources.recipe_card_minutes_format import recipe.composeapp.generated.resources.recipe_card_minutes_format
import recipe.composeapp.generated.resources.sample_recipe import recipe.composeapp.generated.resources.sample_recipe
@Composable @Composable
internal fun RecipeDetailHero( internal fun RecipeDetailHero(
title: String,
cookingMinutes: Int, cookingMinutes: Int,
onPlanClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val spacing = RecipeTheme.spacing val spacing = RecipeTheme.spacing
Column( Column(
@@ -55,7 +45,6 @@ internal fun RecipeDetailHero(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
top = HERO_TOP_PADDING,
bottom = spacing.lg, bottom = spacing.lg,
start = spacing.lg, start = spacing.lg,
end = spacing.lg, end = spacing.lg,
@@ -81,31 +70,11 @@ internal fun RecipeDetailHero(
Spacer(Modifier.height(spacing.lg)) Spacer(Modifier.height(spacing.lg))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth()) {
Column( MetaChip(
modifier = Modifier.weight(1f), icon = Lucide.Clock,
horizontalAlignment = Alignment.Start, text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
verticalArrangement =Arrangement.spacedBy(spacing.lg), )
) {
BasicText(
text = title,
style =
typography.display.copy(
color = colors.content,
fontSize = TITLE_FONT_SIZE,
lineHeight = TITLE_LINE_HEIGHT,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
),
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
MetaChip(
icon = Lucide.Clock,
text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
)
}
}
PlanButton(onClick = onPlanClick)
} }
} }
} }
@@ -139,38 +108,13 @@ private fun MetaChip(
} }
} }
@Composable
private fun PlanButton(
onClick: () -> Unit,
) {
CircleButton(
onClick = onClick,
icon = Lucide.Calendar,
contentDescription = stringResource(Res.string.meal_plan_editor_title_a11y),
size = PLAN_BUTTON_SIZE,
iconSize = PLAN_BUTTON_ICON_SIZE,
tint = RecipeTheme.colors.surface,
iconTint = RecipeTheme.colors.accent,
borderTint = RecipeTheme.colors.borderCard,
borderWidth = 1.dp,
)
}
private const val BANNER_ASPECT_RATIO = 16f / 9f private const val BANNER_ASPECT_RATIO = 16f / 9f
private val BANNER_CORNER = 20.dp private val BANNER_CORNER = 20.dp
private val BANNER_SHADOW_ELEVATION = 14.dp private val BANNER_SHADOW_ELEVATION = 14.dp
private val BANNER_SHADOW_COLOR = Color.Black.copy(alpha = 0.45f) private val BANNER_SHADOW_COLOR = Color.Black.copy(alpha = 0.45f)
// Leave room for the sheet handle (8dp top padding + 5dp handle) plus breathing room.
private val HERO_TOP_PADDING = 32.dp
private val TITLE_FONT_SIZE = 19.sp
private val TITLE_LINE_HEIGHT = 20.sp
private val CHIP_SHAPE = RoundedCornerShape(percent = 50) private val CHIP_SHAPE = RoundedCornerShape(percent = 50)
private val CHIP_PADDING_H = 12.dp private val CHIP_PADDING_H = 12.dp
private val CHIP_PADDING_V = 7.dp private val CHIP_PADDING_V = 7.dp
private val CHIP_ICON_SIZE = 14.dp private val CHIP_ICON_SIZE = 14.dp
private val CHIP_ICON_GAP = 5.dp private val CHIP_ICON_GAP = 5.dp
private val PLAN_BUTTON_SIZE = 50.dp
private val PLAN_BUTTON_ICON_SIZE = 25.dp

View File

@@ -1,39 +1,99 @@
package dev.ulfrx.recipe.ui.screens.recipedetail package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue 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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Calendar
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.meal_plan_editor_title_a11y
import recipe.composeapp.generated.resources.recipe_detail_close_a11y
import recipe.composeapp.generated.resources.recipe_detail_not_found import recipe.composeapp.generated.resources.recipe_detail_not_found
@Composable @Composable
internal fun RecipeDetailScreen( internal fun RecipeDetailScreen(
viewModel: RecipeDetailViewModel, viewModel: RecipeDetailViewModel,
onPlan: (RecipeDetailState.Ready) -> Unit, onPlan: (RecipeDetailState.Ready) -> Unit,
onClose: () -> Unit,
) { ) {
val backdrop = rememberGlassBackdropState()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) { val spacing = RecipeTheme.spacing
is RecipeDetailState.Ready ->
RecipeDetailContent(
ready = s,
onPlanClick = { onPlan(s) },
onServingsChange = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
)
RecipeDetailState.NotFound -> CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(
BasicText( modifier =
text = stringResource(Res.string.recipe_detail_not_found), Modifier
style = RecipeTheme.typography.body, .fillMaxSize()
) .background(RecipeTheme.colors.background),
) {
when (val s = state) {
is RecipeDetailState.Ready -> {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
RecipeDetailContent(
ready = s,
onServingsChange = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
topChromeInset = TopActionsTopInset,
topChromeHeight = TopPillHeight,
)
}
CircleGlassButton(
onClick = { onPlan(s) },
icon = Lucide.Calendar,
contentDescription = stringResource(Res.string.meal_plan_editor_title_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = TopActionsTopInset, end = spacing.lg),
)
}
RecipeDetailState.NotFound -> {
BasicText(
text = stringResource(Res.string.recipe_detail_not_found),
style = RecipeTheme.typography.body,
modifier = Modifier.align(Alignment.Center),
)
}
} }
CircleGlassButton(
onClick = onClose,
icon = Lucide.X,
contentDescription = stringResource(Res.string.recipe_detail_close_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
modifier =
Modifier
.align(Alignment.TopStart)
.padding(top = TopActionsTopInset, start = spacing.lg),
)
}
} }
} }
private val TopPillHeight = 44.dp
private val TopActionIconSize = 18.dp
private val TopActionsTopInset = 28.dp

View File

@@ -20,6 +20,7 @@ fun RecipeSheet(state: RecipeBottomSheetState<Screen>) {
RecipeDetailScreen( RecipeDetailScreen(
viewModel = vm, viewModel = vm,
onClose = state::dismiss,
onPlan = { ready -> onPlan = { ready ->
state.push( state.push(
Screen.MealPlanEditor.Open( Screen.MealPlanEditor.Open(