Restyle recipe details top bar
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
<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="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>
|
||||
|
||||
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
||||
|
||||
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -47,7 +49,14 @@ fun <T : NavKey> RecipeBottomSheet(
|
||||
modifier: Modifier = Modifier,
|
||||
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 viewModelDecorator = rememberViewModelStoreNavEntryDecorator<T>()
|
||||
|
||||
@@ -62,9 +71,11 @@ fun <T : NavKey> RecipeBottomSheet(
|
||||
)
|
||||
Sheet(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = !dismissalGate.isBlocked,
|
||||
backgroundColor = RecipeTheme.colors.background,
|
||||
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
|
||||
) {
|
||||
CompositionLocalProvider(LocalSheetDismissalGate provides dismissalGate) {
|
||||
SheetBody {
|
||||
if (state.backStack.isNotEmpty()) {
|
||||
NavDisplay(
|
||||
@@ -80,6 +91,7 @@ fun <T : NavKey> RecipeBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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.LocalGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.sheet.BlockSheetDismiss
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
@@ -48,6 +49,7 @@ internal fun MealPlanEditorScreen(
|
||||
) {
|
||||
when (val s = state) {
|
||||
is MealPlanEditorState.Editing -> {
|
||||
BlockSheetDismiss()
|
||||
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
|
||||
MealPlanEditorContent(
|
||||
editing = s,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
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
|
||||
@@ -19,6 +20,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.sp
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientCard
|
||||
@@ -45,9 +49,10 @@ import recipe.composeapp.generated.resources.recipe_detail_step_number_format
|
||||
@Composable
|
||||
internal fun RecipeDetailContent(
|
||||
ready: RecipeDetailState.Ready,
|
||||
onPlanClick: () -> Unit,
|
||||
onServingsChange: (Int) -> Unit,
|
||||
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
||||
topChromeInset: Dp,
|
||||
topChromeHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = RecipeTheme.spacing
|
||||
@@ -58,11 +63,35 @@ internal fun RecipeDetailContent(
|
||||
val servings = ready.servings
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
RecipeDetailHero(
|
||||
title = detail.title,
|
||||
cookingMinutes = detail.cookingMinutes,
|
||||
onPlanClick = onPlanClick,
|
||||
Spacer(Modifier.height(topChromeInset))
|
||||
// Aligns the title row with the floating close chrome at scroll=0:
|
||||
// same height, padded inside the chrome's circle pill.
|
||||
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)) {
|
||||
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 StepNumberTextSize = 11.sp
|
||||
private val StepTextSize = 13.sp
|
||||
|
||||
@@ -22,32 +22,22 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.sp
|
||||
import com.composables.icons.lucide.Calendar
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.components.button.CircleButton
|
||||
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.meal_plan_editor_title_a11y
|
||||
import recipe.composeapp.generated.resources.recipe_card_minutes_format
|
||||
import recipe.composeapp.generated.resources.sample_recipe
|
||||
|
||||
@Composable
|
||||
internal fun RecipeDetailHero(
|
||||
title: String,
|
||||
cookingMinutes: Int,
|
||||
onPlanClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val typography = RecipeTheme.typography
|
||||
val spacing = RecipeTheme.spacing
|
||||
|
||||
Column(
|
||||
@@ -55,7 +45,6 @@ internal fun RecipeDetailHero(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
top = HERO_TOP_PADDING,
|
||||
bottom = spacing.lg,
|
||||
start = spacing.lg,
|
||||
end = spacing.lg,
|
||||
@@ -81,33 +70,13 @@ internal fun RecipeDetailHero(
|
||||
|
||||
Spacer(Modifier.height(spacing.lg))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
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)) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
MetaChip(
|
||||
icon = Lucide.Clock,
|
||||
text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
|
||||
)
|
||||
}
|
||||
}
|
||||
PlanButton(onClick = onPlanClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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 val BANNER_CORNER = 20.dp
|
||||
private val BANNER_SHADOW_ELEVATION = 14.dp
|
||||
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_PADDING_H = 12.dp
|
||||
private val CHIP_PADDING_V = 7.dp
|
||||
private val CHIP_ICON_SIZE = 14.dp
|
||||
private val CHIP_ICON_GAP = 5.dp
|
||||
private val PLAN_BUTTON_SIZE = 50.dp
|
||||
private val PLAN_BUTTON_ICON_SIZE = 25.dp
|
||||
|
||||
@@ -1,39 +1,99 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 org.jetbrains.compose.resources.stringResource
|
||||
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
|
||||
|
||||
@Composable
|
||||
internal fun RecipeDetailScreen(
|
||||
viewModel: RecipeDetailViewModel,
|
||||
onPlan: (RecipeDetailState.Ready) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val backdrop = rememberGlassBackdropState()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val spacing = RecipeTheme.spacing
|
||||
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
when (val s = state) {
|
||||
is RecipeDetailState.Ready ->
|
||||
is RecipeDetailState.Ready -> {
|
||||
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
|
||||
RecipeDetailContent(
|
||||
ready = s,
|
||||
onPlanClick = { onPlan(s) },
|
||||
onServingsChange = viewModel::setServings,
|
||||
onSelectSubstitution = viewModel::selectSubstitution,
|
||||
topChromeInset = TopActionsTopInset,
|
||||
topChromeHeight = TopPillHeight,
|
||||
)
|
||||
}
|
||||
|
||||
RecipeDetailState.NotFound ->
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
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
|
||||
|
||||
@@ -20,6 +20,7 @@ fun RecipeSheet(state: RecipeBottomSheetState<Screen>) {
|
||||
|
||||
RecipeDetailScreen(
|
||||
viewModel = vm,
|
||||
onClose = state::dismiss,
|
||||
onPlan = { ready ->
|
||||
state.push(
|
||||
Screen.MealPlanEditor.Open(
|
||||
|
||||
Reference in New Issue
Block a user