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(