diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index bd5745f..fc0dafc 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -50,6 +50,7 @@
Zwiększ liczbę porcji
Przeciągnij w dół, aby zamknąć
Nie znaleziono przepisu
+ Zamknij szczegóły przepisu
Nie udało się otworzyć edytora
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheet.kt
index 397a7a8..d4b9bad 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheet.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/RecipeBottomSheet.kt
@@ -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 RecipeBottomSheet(
modifier: Modifier = Modifier,
entries: EntryProviderScope.() -> 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()
val viewModelDecorator = rememberViewModelStoreNavEntryDecorator()
@@ -62,20 +71,23 @@ fun RecipeBottomSheet(
)
Sheet(
modifier = modifier.fillMaxWidth(),
+ enabled = !dismissalGate.isBlocked,
backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
) {
- SheetBody {
- if (state.backStack.isNotEmpty()) {
- NavDisplay(
- backStack = state.backStack,
- modifier = Modifier.fillMaxSize(),
- onBack = { state.pop() },
- entryDecorators = listOf(saveableDecorator, viewModelDecorator),
- entryProvider = entryProvider(builder = entries),
- )
- } else {
- Box(modifier = Modifier.fillMaxSize())
+ CompositionLocalProvider(LocalSheetDismissalGate provides dismissalGate) {
+ SheetBody {
+ if (state.backStack.isNotEmpty()) {
+ NavDisplay(
+ backStack = state.backStack,
+ modifier = Modifier.fillMaxSize(),
+ onBack = { state.pop() },
+ entryDecorators = listOf(saveableDecorator, viewModelDecorator),
+ entryProvider = entryProvider(builder = entries),
+ )
+ } else {
+ Box(modifier = Modifier.fillMaxSize())
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/SheetDismissalGate.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/SheetDismissalGate.kt
new file mode 100644
index 0000000..33cf489
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/sheet/SheetDismissalGate.kt
@@ -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 { 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() }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorEntry.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorEntry.kt
index 7aaeb59..9ea2f14 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorEntry.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorEntry.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailContent.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailContent.kt
index 18189a8..2e82a1c 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailContent.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailContent.kt
@@ -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
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
index ee44fe0..9268df1 100644
--- 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
@@ -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,31 +70,11 @@ 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)) {
- MetaChip(
- icon = Lucide.Clock,
- text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
- )
- }
- }
- PlanButton(onClick = onPlanClick)
+ Row(modifier = Modifier.fillMaxWidth()) {
+ MetaChip(
+ icon = Lucide.Clock,
+ text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
+ )
}
}
}
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailScreen.kt
index adac137..c490363 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailScreen.kt
@@ -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()
- when (val s = state) {
- is RecipeDetailState.Ready ->
- RecipeDetailContent(
- ready = s,
- onPlanClick = { onPlan(s) },
- onServingsChange = viewModel::setServings,
- onSelectSubstitution = viewModel::selectSubstitution,
- )
+ val spacing = RecipeTheme.spacing
- RecipeDetailState.NotFound ->
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- BasicText(
- text = stringResource(Res.string.recipe_detail_not_found),
- style = RecipeTheme.typography.body,
- )
+ CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
+ Box(
+ modifier =
+ Modifier
+ .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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipesheet/RecipeSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipesheet/RecipeSheet.kt
index 9473a92..d6aef43 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipesheet/RecipeSheet.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipesheet/RecipeSheet.kt
@@ -20,6 +20,7 @@ fun RecipeSheet(state: RecipeBottomSheetState) {
RecipeDetailScreen(
viewModel = vm,
+ onClose = state::dismiss,
onPlan = { ready ->
state.push(
Screen.MealPlanEditor.Open(