Add meal plan editor + smaller changes

This commit is contained in:
2026-06-04 17:24:33 +02:00
parent 121f79109a
commit d1916d3fe6
49 changed files with 2778 additions and 606 deletions

View File

@@ -0,0 +1,18 @@
package dev.ulfrx.recipe.ui.keyboard
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
@Composable
internal actual fun rememberKeyboardTransitionState(): KeyboardTransitionState {
val imeInset = WindowInsets.ime.asPaddingValues().calculateBottomPadding()
return KeyboardTransitionState(
currentInset = imeInset,
targetInset = imeInset,
animationDurationMillis = AndroidKeyboardAnimationDurationMillis,
)
}
private const val AndroidKeyboardAnimationDurationMillis = 250

View File

@@ -76,4 +76,28 @@
<!-- Dummy metryki pilla (UI-first; realne dane w fazach 8/9) -->
<string name="pantry_shortfall_count">%1$d braków</string>
<string name="shopping_buy_count">%1$d do kupienia</string>
<!-- Pory posiłku — wspólne dla detalu i edytora planu (Phase 6 polishes copy + plurals) -->
<string name="meal_slot_breakfast">Śniadanie</string>
<string name="meal_slot_lunch">Lunch</string>
<string name="meal_slot_dinner">Obiad</string>
<string name="meal_slot_supper">Kolacja</string>
<string name="meal_slot_snack">Przekąska</string>
<!-- Phase 6 — Meal plan editor (UI-first; planStore wiring lands in later phases) -->
<string name="meal_plan_editor_title">Zaplanuj posiłek</string>
<string name="meal_plan_editor_back_a11y">Wróć do szczegółów przepisu</string>
<string name="meal_plan_editor_confirm">Dodaj</string>
<string name="meal_plan_editor_confirm_a11y">Dodaj posiłek do planu</string>
<string name="meal_plan_editor_section_slot">Pora posiłku</string>
<string name="meal_plan_editor_section_servings">Porcje</string>
<string name="meal_plan_editor_section_ingredients">Składniki</string>
<string name="meal_plan_editor_add_ingredient">Dodaj składnik</string>
<string name="meal_plan_editor_add_ingredient_search_placeholder">Szukaj składnika…</string>
<string name="meal_plan_editor_add_ingredient_cancel">Anuluj</string>
<string name="meal_plan_editor_add_ingredient_empty">Brak wyników</string>
<string name="meal_plan_editor_removed_format">%1$d usuniętych</string>
<string name="meal_plan_editor_removed_restore">Przywróć</string>
<string name="meal_plan_editor_remove_ingredient_a11y">Usuń składnik</string>
<string name="meal_plan_editor_added_marker_a11y">Dodany składnik</string>
</resources>

View File

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
@@ -19,4 +20,5 @@ val shellModule =
viewModel<ShellSearchViewModel>()
viewModel<RecipeCatalogViewModel>()
viewModel<RecipeDetailViewModel>()
viewModel<MealPlanEditorViewModel>()
}

View File

@@ -3,16 +3,20 @@ package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -27,6 +31,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
@@ -35,13 +41,30 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import dev.ulfrx.recipe.ui.theme.lerp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
enum class CalendarPillExpandDirection {
/** Pill anchored at the bottom; calendar slides into view from above (planner pattern). */
Up,
/** Pill anchored at the top; calendar grows downward beneath it (in-sheet editor pattern). */
Down,
;
/** Sign convention: positive drag/velocity along this axis opens the pill. */
val openingSign: Float
get() =
when (this) {
Up -> -1f
Down -> 1f
}
}
@Composable
fun CalendarPill(
expanded: Boolean,
@@ -56,6 +79,9 @@ fun CalendarPill(
dayState: (LocalDate) -> DayState = { DayState() },
pillHeight: Dp = 48.dp,
locale: CalendarLocale = CalendarLocale.PL,
expandDirection: CalendarPillExpandDirection = CalendarPillExpandDirection.Up,
tint: Color = RecipeTheme.colors.surfaceGlass,
glass: Boolean = true,
) {
val scope = rememberCoroutineScope()
val expansion = remember { PillExpansion(initial = if (expanded) 1f else 0f) }
@@ -66,34 +92,44 @@ fun CalendarPill(
val progress = expansion.progress
val cornerRadius = pillHeight / 2 * (1f - progress) + EXPANDED_CORNER_RADIUS * progress
val glassStyle = lerp(RecipeTheme.glass.menu, RecipeTheme.glass.panel, progress)
val pillInset = RecipeTheme.spacing.lg + RecipeTheme.spacing.xs
val pillHeightPx = with(LocalDensity.current) { pillHeight.toPx() }
val dragState =
rememberDraggableState { delta ->
expansion.dragBy(delta, range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f))
expansion.dragBy(
delta = delta,
range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f),
direction = expandDirection,
)
}
GlassSurface(
PillSurface(
glass = glass,
tint = tint,
cornerRadius = cornerRadius,
glassStyle = if (expanded) RecipeTheme.glass.panel else RecipeTheme.glass.dock,
modifier =
modifier.draggable(
state = dragState,
orientation = Orientation.Vertical,
onDragStarted = { expansion.cancelSettle() },
onDragStopped = { velocity ->
val openTarget = releaseTarget(expansion.progress, velocity)
val openTarget = releaseTarget(expansion.progress, velocity, expandDirection)
val range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f)
expansion.animateTo(scope, if (openTarget) 1f else 0f, initialVelocity = -velocity / range)
val initialVelocity = expandDirection.openingSign * velocity / range
expansion.animateTo(scope, if (openTarget) 1f else 0f, initialVelocity = initialVelocity)
if (openTarget != expanded) onExpandedChange(openTarget)
},
),
cornerRadius = cornerRadius,
glassStyle = glassStyle,
) {
Box(modifier = Modifier.fillMaxWidth()) {
CompositionLocalProvider(LocalCalendarInteractive provides expanded) {
Box(
modifier = Modifier.fillMaxWidth().expandingHeight(progress, pillHeight, expansion).alpha(progress),
modifier =
Modifier
.fillMaxWidth()
.expandingHeight(progress, pillHeight, expansion, expandDirection)
.alpha(progress),
) {
SwipeableCalendar(
selectedDate = selectedDate,
@@ -112,11 +148,16 @@ fun CalendarPill(
val rowAlpha = (1f - progress / PILL_CONTENT_FADE_END).coerceIn(0f, 1f)
if (rowAlpha > 0f) {
val pillRowAlignment =
when (expandDirection) {
CalendarPillExpandDirection.Up -> Alignment.BottomCenter
CalendarPillExpandDirection.Down -> Alignment.TopCenter
}
Box(
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.align(pillRowAlignment)
.alpha(rowAlpha),
) {
PillRow(
@@ -132,6 +173,43 @@ fun CalendarPill(
}
}
/**
* Surface wrapper for the pill. Glass mode is the default and matches the
* planner pattern where the pill sits over a varied app-shell backdrop and
* refraction earns its keep. The flat mode is for in-sheet contexts where the
* backdrop is mostly a solid colour — refraction has nothing meaningful to
* refract and only adds visual noise.
*/
@Composable
private fun PillSurface(
glass: Boolean,
tint: Color,
cornerRadius: Dp,
glassStyle: RecipeGlassStyle,
modifier: Modifier,
content: @Composable BoxScope.() -> Unit,
) {
if (glass) {
GlassSurface(
modifier = modifier,
cornerRadius = cornerRadius,
glassStyle = glassStyle,
content = content,
)
} else {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.background(tint)
.border(width = FlatBorderWidth, color = colors.borderCard, shape = shape),
content = content,
)
}
}
@Composable
private fun PillRow(
label: String,
@@ -166,13 +244,16 @@ private fun PillRow(
/**
* Measures the calendar at its full intrinsic height, reports it to [expansion]
* so drag knows the range, then lays out at the lerped height anchored to the
* bottom edge so the calendar slides down from above the pill row.
* so drag knows the range, then lays out at the lerped height. The placement
* anchor flips with [direction]: anchoring the calendar's bottom edge makes it
* slide in from above (pill at bottom); anchoring the top edge makes the
* calendar reveal downward (pill at top).
*/
private fun Modifier.expandingHeight(
progress: Float,
pillHeight: Dp,
expansion: PillExpansion,
direction: CalendarPillExpandDirection,
): Modifier =
this.layout { measurable, constraints ->
val placeable =
@@ -181,7 +262,12 @@ private fun Modifier.expandingHeight(
val pillHeightPx = pillHeight.roundToPx()
val height = lerp(pillHeightPx, placeable.height, progress).coerceIn(pillHeightPx, placeable.height)
layout(placeable.width, height) {
placeable.place(0, height - placeable.height)
val placementY =
when (direction) {
CalendarPillExpandDirection.Up -> height - placeable.height
CalendarPillExpandDirection.Down -> 0
}
placeable.place(0, placementY)
}
}
@@ -205,9 +291,10 @@ private class PillExpansion(
fun dragBy(
delta: Float,
range: Float,
direction: CalendarPillExpandDirection,
) {
settleJob?.cancel()
progress = (progress - delta / range).coerceIn(0f, 1f)
progress = (progress + direction.openingSign * delta / range).coerceIn(0f, 1f)
target = progress
}
@@ -247,13 +334,17 @@ private class PillExpansion(
private fun releaseTarget(
progress: Float,
velocity: Float,
): Boolean =
when {
velocity <= -FLING_VELOCITY -> true
velocity >= FLING_VELOCITY -> false
direction: CalendarPillExpandDirection,
): Boolean {
val openingVelocity = direction.openingSign * velocity
return when {
openingVelocity >= FLING_VELOCITY -> true
openingVelocity <= -FLING_VELOCITY -> false
else -> progress >= 0.5f
}
}
private const val FLING_VELOCITY = 60f
private const val PILL_CONTENT_FADE_END = 0.35f
private val EXPANDED_CORNER_RADIUS = 28.dp
private val FlatBorderWidth = 1.dp

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.screens.planner
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -9,14 +9,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.components.calendar.CalendarDayCell
import dev.ulfrx.recipe.ui.components.calendar.CalendarLocale
import dev.ulfrx.recipe.ui.components.calendar.DayState
import dev.ulfrx.recipe.ui.components.calendar.weekStripDays
import kotlinx.datetime.LocalDate
/**
* Mon-anchored 7-day strip rendering [CalendarDayCell] per day. Used by every
* surface that embeds [CalendarPill] in its collapsed form (planner, meal-plan
* editor, future pantry/shopping pills).
*/
@Composable
fun PlannerWeekStrip(
fun CalendarWeekStrip(
selectedDate: LocalDate,
today: LocalDate,
onSelectDate: (LocalDate) -> Unit,
@@ -28,7 +29,7 @@ fun PlannerWeekStrip(
val days = weekStripDays(selectedDate)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(DayCellGap),
verticalAlignment = Alignment.CenterVertically,
) {
days.forEachIndexed { index, day ->
@@ -46,3 +47,5 @@ fun PlannerWeekStrip(
}
}
}
private val DayCellGap = 4.dp

View File

@@ -0,0 +1,128 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
/**
* Paged version of [CalendarWeekStrip] — horizontally swipeable. Each page
* renders one week's days; swiping fires [onSelectionShift] with the same
* weekday in the now-visible week so the caller can move the highlighted day
* along with the navigation. Tapping a day still goes through [onSelectDate].
*/
@Composable
fun CalendarWeekStripPager(
selectedDate: LocalDate,
today: LocalDate,
onSelectDate: (LocalDate) -> Unit,
onSelectionShift: (LocalDate) -> Unit,
numberStyle: TextStyle,
modifier: Modifier = Modifier,
dayState: (LocalDate) -> DayState = { DayState() },
locale: CalendarLocale = CalendarLocale.PL,
) {
val origin = remember { selectedDate }
val initialPage = remember { PAGE_COUNT / 2 }
val pagerState = rememberPagerState(initialPage = initialPage) { PAGE_COUNT }
val currentOnSelectionShift by rememberUpdatedState(onSelectionShift)
// Bring the pager onto the page that contains [selectedDate] whenever it
// changes from outside the pager — e.g., the user picked a day from the
// expanded month grid before collapsing.
LaunchedEffect(selectedDate) {
val target = initialPage + periodsBetween(origin, selectedDate, CalendarMode.Week)
if (target != pagerState.currentPage) {
pagerState.animateScrollToPage(target)
}
}
// Report swipe-driven page changes upward as "shift selection to the same
// weekday in the now-visible week" so the highlight follows the navigation.
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }
.distinctUntilChanged()
.collect { page ->
if (page == initialPage) return@collect
val visibleWeekAnchor = origin.plusPeriods(page - initialPage, CalendarMode.Week)
if (!isInVisiblePeriod(selectedDate, visibleWeekAnchor, CalendarMode.Week)) {
val deltaWeeks = page - initialPage
currentOnSelectionShift(selectedDate.plus(DatePeriod(days = deltaWeeks * DAYS_PER_WEEK)))
}
}
}
HorizontalPager(
state = pagerState,
modifier = modifier.fillMaxWidth(),
pageSpacing = 0.dp,
) { page ->
val pageAnchor = origin.plusPeriods(page - initialPage, CalendarMode.Week)
WeekStripWithHeaders(
anchor = pageAnchor,
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate,
numberStyle = numberStyle,
dayState = dayState,
locale = locale,
)
}
}
@Composable
private fun WeekStripWithHeaders(
anchor: LocalDate,
selectedDate: LocalDate,
today: LocalDate,
onSelectDate: (LocalDate) -> Unit,
numberStyle: TextStyle,
dayState: (LocalDate) -> DayState,
locale: CalendarLocale,
) {
val days = weekStripDays(anchor)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DayCellGap),
verticalAlignment = Alignment.CenterVertically,
) {
days.forEachIndexed { index, day ->
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = dayState(day),
isSelected = day == selectedDate,
isToday = day == today,
onClick = { onSelectDate(day) },
numberStyle = numberStyle,
header = locale.weekdaysShort[index],
)
}
}
}
}
private const val DAYS_PER_WEEK = 7
// Centered start lets the pager scroll forward and backward freely — mirrors
// the convention used by [SwipeableCalendar]; 100k pages in either direction is
// ~1900 years so users will never run off the edge.
private const val PAGE_COUNT: Int = 200_000
private val DayCellGap = 4.dp

View File

@@ -0,0 +1,83 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
/**
* Project-default wrapping of [CalendarPill] — collapsed state shows a paged
* week strip plus the current month's short name. Used by the planner pill,
* the meal-plan editor's in-sheet calendar, and any other surface that wants
* the "swipe weeks, drag to expand to a month grid" pattern.
*
* Callers tweak [expandDirection] / [glass] / [tint] / [plannedDates] to match
* their host context but the layout, typography and gesture handling stay
* unified across screens.
*/
@Composable
fun RecipeCalendarPill(
selectedDate: LocalDate,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
onSelectionShift: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
plannedDates: Set<LocalDate> = emptySet(),
expandDirection: CalendarPillExpandDirection = CalendarPillExpandDirection.Up,
glass: Boolean = true,
tint: Color = RecipeTheme.colors.surfaceGlass,
locale: CalendarLocale = CalendarLocale.PL,
) {
val today = remember { todayInSystemTz() }
val dayState =
remember(plannedDates) {
{ date: LocalDate -> DayState(indicator = date in plannedDates) }
}
val pillTextStyle =
RecipeTheme.typography.label.copy(
fontWeight = FontWeight.Light,
fontSize = PillTextSize,
)
val handleDayPick: (LocalDate) -> Unit = { date ->
onSelectDate(date)
if (expanded) onExpandedChange(false)
}
CalendarPill(
expanded = expanded,
onExpandedChange = onExpandedChange,
selectedDate = selectedDate,
today = today,
onSelectDate = handleDayPick,
expandDirection = expandDirection,
glass = glass,
tint = tint,
collapsedContent = {
CalendarWeekStripPager(
selectedDate = selectedDate,
today = today,
onSelectDate = handleDayPick,
onSelectionShift = onSelectionShift,
numberStyle = pillTextStyle,
dayState = dayState,
modifier = Modifier.weight(1f),
)
BasicText(
text = locale.monthsShort[selectedDate.monthNumber - 1],
style = pillTextStyle.copy(color = RecipeTheme.colors.contentMuted),
)
},
dayState = dayState,
modifier = modifier.fillMaxWidth(),
)
}
private val PillTextSize = 12.sp

View File

@@ -0,0 +1,82 @@
package dev.ulfrx.recipe.ui.components.chips
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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
import com.composeunstyled.UnstyledButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Selectable chip for meal-plan slots (śniadanie / lunch / obiad / kolacja /
* przekąska). Flat surface — no glass refraction — because the chip row sits
* on the editor's static background where liquid effects add visual noise
* without revealing anything underneath. Disabled state renders for slots not
* in the recipe's `allowedSlots`.
*/
@Composable
fun MealSlotChip(
label: String,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(ChipCornerRadius)
val backgroundColor =
when {
!enabled -> Color.Transparent
selected -> colors.accent.copy(alpha = SelectedBackgroundAlpha)
else -> colors.surface
}
val borderColor =
when {
!enabled -> Color.Transparent
selected -> colors.accent.copy(alpha = SelectedBorderAlpha)
else -> colors.borderCard
}
val labelColor =
when {
!enabled -> colors.contentMuted.copy(alpha = DisabledLabelAlpha)
selected -> colors.accent
else -> colors.content
}
UnstyledButton(
onClick = onClick,
enabled = enabled,
backgroundColor = backgroundColor,
contentColor = labelColor,
shape = shape,
borderColor = borderColor,
borderWidth = if (borderColor == Color.Transparent) 0.dp else BorderWidth,
contentPadding = PaddingValues(horizontal = HorizontalPadding, vertical = VerticalPadding),
modifier = modifier,
) {
BasicText(
text = label,
style =
RecipeTheme.typography.label.copy(
color = labelColor,
fontWeight = FontWeight.Normal,
fontSize = LabelTextSize,
),
)
}
}
private const val SelectedBackgroundAlpha = 0.18f
private const val SelectedBorderAlpha = 0.55f
private const val DisabledLabelAlpha = 0.45f
private val ChipCornerRadius = 14.dp
private val BorderWidth = 1.dp
private val HorizontalPadding = 10.dp
private val VerticalPadding = 7.dp
private val LabelTextSize = 11.sp

View File

@@ -1,6 +1,5 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -22,6 +21,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
@Composable
@@ -33,21 +33,16 @@ fun CircleGlassButton(
size: Dp = 48.dp,
iconSize: Dp = 24.dp,
iconTint: Color = RecipeTheme.colors.content,
glassStyle: RecipeGlassStyle = RecipeTheme.glass.dock,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressedTint = Color.White.copy(alpha = 0.18f)
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.15f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "CircleGlassButton scale",
)
val tint by animateColorAsState(
targetValue = if (isPressed) pressedTint else RecipeTheme.colors.surfaceGlass,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "CircleGlassButton tint",
)
GlassSurface(
modifier =
@@ -55,7 +50,7 @@ fun CircleGlassButton(
.scale(scale)
.size(size),
cornerRadius = size / 2,
tint = tint,
glassStyle = glassStyle,
) {
UnstyledButton(
onClick = onClick,

View File

@@ -36,7 +36,8 @@ fun GlassBackdropSource(
content: @Composable BoxScope.() -> Unit,
) {
Box(
modifier = modifier.liquefiable(state.liquidState),
modifier = modifier
.liquefiable(state.liquidState),
content = content,
)
}

View File

@@ -6,7 +6,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
@@ -26,9 +25,8 @@ import io.github.fletchmckee.liquid.liquid
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
glassStyle: RecipeGlassStyle = RecipeTheme.glass.menu,
glassStyle: RecipeGlassStyle = RecipeTheme.glass.dock,
recordAsSource: Boolean = false,
content: @Composable BoxScope.() -> Unit,
) {
@@ -48,7 +46,7 @@ fun GlassSurface(
contrast = glassStyle.contrast
frost = glassStyle.frost
this.shape = shape
this.tint = tint
glassStyle.tint?.let { this.tint = it }
},
content = content,
)

View File

@@ -25,7 +25,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
@@ -102,10 +104,7 @@ fun GlassTextField(
if (value.isEmpty()) {
BasicText(
text = placeholder,
style =
RecipeTheme.typography.body.copy(
color = Color.White,
),
style = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
)
}
innerField()

View File

@@ -0,0 +1,65 @@
package dev.ulfrx.recipe.ui.components.recipe
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
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.text.font.FontWeight
import androidx.compose.ui.unit.sp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlin.math.round
/**
* Right-aligned amount + unit pair shared by [IngredientRow] (recipe detail
* and meal-plan editor) and the addable-catalog rows in the "Dodaj składnik"
* search panel. Amount is locale-formatted with a comma decimal; unit is
* rendered muted so the value reads as primary.
*/
@Composable
fun IngredientAmount(
amount: Double,
unit: String,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
BasicText(
text = formatIngredientAmount(amount),
style =
typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = AmountTextSize,
lineHeight = AmountLineHeight,
),
)
BasicText(
text = unit,
style =
typography.body.copy(
color = colors.contentMuted,
fontSize = UnitTextSize,
lineHeight = AmountLineHeight,
),
)
}
}
/** One-decimal-place comma-formatted amount: 200.0 → "200", 1.5 → "1,5". */
internal fun formatIngredientAmount(value: Double): String {
val scaled = round(value * 10.0).toLong()
val whole = scaled / 10
val frac = (scaled % 10).toInt()
return if (frac == 0) whole.toString() else "$whole,$frac"
}
private val AmountTextSize = 12.sp
private val UnitTextSize = 11.sp
private val AmountLineHeight = 16.sp

View File

@@ -0,0 +1,40 @@
package dev.ulfrx.recipe.ui.components.recipe
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Wrapping card used by both the read-only recipe detail and the meal-plan
* editor to host a list of [IngredientRow]s separated by [IngredientDivider].
* Surface, border and corner radius are unified so the two screens read as the
* same widget rendered against different sources of truth.
*/
@Composable
fun IngredientCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(CardCornerRadius)
Column(
modifier =
modifier
.fillMaxWidth()
.clip(shape)
.background(colors.surface)
.border(width = CardBorderWidth, color = colors.borderCard, shape = shape),
) {
content()
}
}
private val CardCornerRadius = 16.dp
private val CardBorderWidth = 1.dp

View File

@@ -0,0 +1,31 @@
package dev.ulfrx.recipe.ui.components.recipe
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Thin separator drawn between consecutive [IngredientRow]s inside the
* shared wrapping ingredient card. Inset matches the row's horizontal
* padding so the line never reaches the card's rounded edges.
*/
@Composable
fun IngredientDivider(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = DividerHorizontalInset)
.height(DividerThickness)
.background(RecipeTheme.colors.separator),
)
}
private val DividerHorizontalInset = 12.dp
private val DividerThickness = 1.dp

View File

@@ -24,19 +24,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.X
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.ingredient_substitute_a11y
import kotlin.math.round
import recipe.composeapp.generated.resources.meal_plan_editor_added_marker_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_remove_ingredient_a11y
data class RecipeIngredientOptionUi(
val id: String,
@@ -51,15 +55,21 @@ data class RecipeIngredientSlotUi(
val id: String = default.id,
)
/**
* Shared row used in both the read-only recipe detail and the meal-plan
* editor. Detail uses the base form (name + optional swap + amount); editor
* passes [onRemove] / [addedMarker] to surface its extra affordances inside
* the same visual language.
*/
@Composable
fun IngredientRow(
slot: RecipeIngredientSlotUi,
modifier: Modifier = Modifier,
selectedOptionId: String = slot.default.id,
onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null,
addedMarker: Boolean = false,
onRemove: (() -> Unit)? = null,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val options = slot.options
val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default
val swappable = slot.alternatives.isNotEmpty() && onSelect != null
@@ -80,21 +90,26 @@ fun IngredientRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
BasicText(
text = selected.name,
style =
typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = NameTextSize,
lineHeight = LineHeight,
),
NameLine(
name = selected.name,
addedMarker = addedMarker,
modifier = Modifier.weight(1f),
)
if (swappable) {
SwapToggle(onClick = { expanded = !expanded })
IconBadgeButton(
icon = Lucide.Shuffle,
contentDescription = stringResource(Res.string.ingredient_substitute_a11y),
onClick = { expanded = !expanded },
)
}
IngredientAmount(amount = selected.amount, unit = selected.unit)
if (onRemove != null) {
IconBadgeButton(
icon = Lucide.X,
contentDescription = stringResource(Res.string.meal_plan_editor_remove_ingredient_a11y),
onClick = onRemove,
)
}
AmountLabel(amount = selected.amount, unit = selected.unit)
}
if (swappable && expanded) {
@@ -121,50 +136,53 @@ fun IngredientRow(
}
@Composable
private fun AmountLabel(
amount: Double,
unit: String,
private fun NameLine(
name: String,
addedMarker: Boolean,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
BasicText(
text = formatAmount(amount),
text = name,
style =
typography.body.copy(
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = NameTextSize,
lineHeight = LineHeight,
),
)
BasicText(
text = unit,
style =
typography.body.copy(
color = colors.contentMuted,
fontSize = UnitTextSize,
lineHeight = LineHeight,
),
)
if (addedMarker) {
UnstyledIcon(
imageVector = Lucide.Plus,
contentDescription = stringResource(Res.string.meal_plan_editor_added_marker_a11y),
tint = colors.contentMuted,
modifier = Modifier.size(AddedMarkerSize),
)
}
}
}
@Composable
private fun SwapToggle(onClick: () -> Unit) {
val colors = RecipeTheme.colors
private fun IconBadgeButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
UnstyledButton(
onClick = onClick,
modifier = Modifier.size(ToggleSize),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
UnstyledIcon(
imageVector = Lucide.Shuffle,
contentDescription = stringResource(Res.string.ingredient_substitute_a11y),
tint = colors.contentMuted,
imageVector = icon,
contentDescription = contentDescription,
tint = RecipeTheme.colors.contentMuted,
modifier = Modifier.size(ToggleIconSize),
)
}
@@ -205,7 +223,7 @@ private fun AlternativeOption(
)
Spacer(Modifier.height(OptionMetaGap))
BasicText(
text = formatAmount(option.amount) + " " + option.unit,
text = formatIngredientAmount(option.amount) + " " + option.unit,
style =
typography.body.copy(
color = colors.contentMuted,
@@ -245,13 +263,6 @@ private fun SelectionMark(selected: Boolean) {
}
}
private fun formatAmount(value: Double): String {
val scaled = round(value * 10.0).toLong()
val whole = scaled / 10
val frac = (scaled % 10).toInt()
return if (frac == 0) whole.toString() else "$whole,$frac"
}
internal val RecipeIngredientSlotUi.options: List<RecipeIngredientOptionUi>
get() = listOf(default) + alternatives
@@ -266,10 +277,10 @@ private val MinRowHeight = 48.dp
private val PaddingHorizontal = 12.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 AddedMarkerSize = 10.dp
private val OptionCornerRadius = 10.dp
private val OptionPadding = 12.dp
private val OptionMetaGap = 2.dp

View File

@@ -0,0 +1,24 @@
package dev.ulfrx.recipe.ui.components.recipe
import org.jetbrains.compose.resources.StringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.meal_slot_breakfast
import recipe.composeapp.generated.resources.meal_slot_dinner
import recipe.composeapp.generated.resources.meal_slot_lunch
import recipe.composeapp.generated.resources.meal_slot_snack
import recipe.composeapp.generated.resources.meal_slot_supper
/**
* Pora posiłku — shared by recipe detail (`allowedSlots`) and the meal-plan
* editor (selected slot + filtered chip row). Ordering reflects the canonical
* daily sequence used in the UI.
*/
enum class MealSlot(
val labelRes: StringResource,
) {
Breakfast(Res.string.meal_slot_breakfast),
Lunch(Res.string.meal_slot_lunch),
Dinner(Res.string.meal_slot_dinner),
Supper(Res.string.meal_slot_supper),
Snack(Res.string.meal_slot_snack),
}

View File

@@ -1,5 +1,7 @@
package dev.ulfrx.recipe.ui.components.recipe
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.Row
@@ -9,10 +11,12 @@ 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.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -23,9 +27,15 @@ 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
/**
* Pill-shaped servings stepper. Flat surface with the standard `colors.surface`
* fill and `borderCard` outline — the same visual treatment used by every
* static editable control across the app (chips, calendar pill, ingredient
* card) so the stepper reads as "part of the page" rather than "floating glass
* chrome".
*/
@Composable
fun RecipeServingsStepper(
servings: Int,
@@ -36,11 +46,14 @@ fun RecipeServingsStepper(
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)
val shape = RoundedCornerShape(STEPPER_HEIGHT / 2)
Box(
modifier =
modifier
.height(STEPPER_HEIGHT)
.clip(shape)
.background(colors.surface)
.border(width = SurfaceBorderWidth, color = colors.borderCard, shape = shape),
) {
Row(
modifier = Modifier.fillMaxHeight(),
@@ -98,9 +111,10 @@ private fun StepperButton(
}
}
private val STEPPER_HEIGHT = 28.dp
private val SurfaceBorderWidth = 1.dp
private val STEPPER_HEIGHT = 36.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
private val STEPPER_BUTTON_WIDTH = 36.dp
private val STEPPER_ICON_SIZE = 14.dp
private val SERVINGS_VALUE_WIDTH = 22.dp
private val SERVINGS_VALUE_TEXT_SIZE = 13.sp

View File

@@ -0,0 +1,43 @@
package dev.ulfrx.recipe.ui.components.section
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/** Uppercase muted label used as a section header across recipe-domain screens. */
@Composable
fun SectionTitle(text: String) {
BasicText(
text = text.uppercase(),
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
fontSize = SectionHeaderTextSize,
letterSpacing = SectionHeaderTracking,
fontWeight = FontWeight.Bold,
),
)
}
/**
* Section title stacked on top of [content] with a fixed `spacing.lg` gap —
* the canonical "header + body" rhythm of the recipe detail and meal-plan
* editor sheets.
*/
@Composable
fun Section(
title: String,
content: @Composable () -> Unit,
) {
SectionTitle(text = title)
Spacer(Modifier.height(RecipeTheme.spacing.lg))
content()
}
private val SectionHeaderTextSize = 11.sp
private val SectionHeaderTracking = 1.sp

View File

@@ -0,0 +1,13 @@
package dev.ulfrx.recipe.ui.keyboard
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
internal data class KeyboardTransitionState(
val currentInset: Dp,
val targetInset: Dp,
val animationDurationMillis: Int,
)
@Composable
internal expect fun rememberKeyboardTransitionState(): KeyboardTransitionState

View File

@@ -0,0 +1,492 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
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.rememberScrollState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Search
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.recipe.IngredientAmount
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
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_add_ingredient
import recipe.composeapp.generated.resources.meal_plan_editor_add_ingredient_cancel
import recipe.composeapp.generated.resources.meal_plan_editor_add_ingredient_empty
import recipe.composeapp.generated.resources.meal_plan_editor_add_ingredient_search_placeholder
/**
* "Dodaj składnik" affordance — collapsed dashed button by default; expands
* into a search panel with filtering against [catalog]. Already-used recipe /
* added ingredient ids are filtered out by [usedIngredientIds] so the user
* never sees the same ingredient twice. Open/closed and the in-flight query
* are pure UI state — survived across recompositions via [rememberSaveable]
* but never lifted into the ViewModel since neither flag matters to confirm.
*/
@Composable
internal fun AddIngredientPanel(
catalog: List<AddableIngredientUi>,
usedIngredientIds: Set<String>,
onPick: (AddableIngredientUi) -> Unit,
modifier: Modifier = Modifier,
maxResults: Int = 20,
keyboardClearance: Dp = 0.dp,
autoFocusEnabled: Boolean = true,
keyboardAnimationDurationMillis: Int = DefaultKeyboardAnimationDurationMillis,
onOpenChange: (Boolean) -> Unit = {},
) {
var isOpen by rememberSaveable { mutableStateOf(false) }
var query by rememberSaveable { mutableStateOf("") }
val focusManager = LocalFocusManager.current
val panelAnimationDurationMillis =
keyboardAnimationDurationMillis.coerceAtLeast(MinPanelAnimationDurationMillis)
LaunchedEffect(isOpen) {
if (isOpen) {
onOpenChange(true)
}
}
Column(modifier = modifier.fillMaxWidth()) {
AnimatedVisibility(
visible = !isOpen,
enter =
fadeIn(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
expandVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
exit =
fadeOut(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
shrinkVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
) {
AddIngredientCollapsedButton(
onClick = {
isOpen = true
onOpenChange(true)
},
)
}
AnimatedVisibility(
visible = isOpen,
enter =
fadeIn(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
expandVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
exit =
fadeOut(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
shrinkVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
) {
AddIngredientSearchCard(
catalog = catalog,
usedIngredientIds = usedIngredientIds,
query = query,
onSetQuery = { query = it },
onClose = {
focusManager.clearFocus(force = true)
isOpen = false
onOpenChange(false)
query = ""
},
onPick = { picked ->
focusManager.clearFocus(force = true)
onPick(picked)
isOpen = false
onOpenChange(false)
query = ""
},
maxResults = maxResults,
keyboardClearance = keyboardClearance,
autoFocusEnabled = autoFocusEnabled,
)
}
}
}
@Composable
private fun AddIngredientCollapsedButton(onClick: () -> Unit) {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(CollapsedCornerRadius)
UnstyledButton(
onClick = onClick,
backgroundColor = Color.Transparent,
contentColor = colors.contentMuted,
shape = shape,
contentPadding = PaddingValues(vertical = CollapsedVerticalPadding),
modifier =
Modifier
.fillMaxWidth()
.border(width = 1.dp, color = colors.borderCard, shape = shape),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
UnstyledIcon(
imageVector = Lucide.Plus,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(CollapsedIconSize),
)
BasicText(
text = stringResource(Res.string.meal_plan_editor_add_ingredient),
style =
RecipeTheme.typography.label.copy(
color = colors.contentMuted,
fontWeight = FontWeight.SemiBold,
fontSize = CollapsedTextSize,
),
)
}
}
}
@Composable
private fun AddIngredientSearchCard(
catalog: List<AddableIngredientUi>,
usedIngredientIds: Set<String>,
query: String,
onSetQuery: (String) -> Unit,
onClose: () -> Unit,
onPick: (AddableIngredientUi) -> Unit,
maxResults: Int,
keyboardClearance: Dp,
autoFocusEnabled: Boolean,
) {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(CardCornerRadius)
val density = LocalDensity.current
val panelBringIntoViewRequester = remember { BringIntoViewRequester() }
val focusRequester = remember { FocusRequester() }
var panelSize by remember { mutableStateOf(IntSize.Zero) }
var focusRequested by remember { mutableStateOf(false) }
val results = remember(catalog, usedIngredientIds, query, maxResults) {
filterCatalog(catalog, usedIngredientIds, query, maxResults)
}
LaunchedEffect(panelSize, keyboardClearance, autoFocusEnabled) {
if (panelSize == IntSize.Zero || !autoFocusEnabled) return@LaunchedEffect
if (!focusRequested) {
focusRequested = true
focusRequester.requestFocus()
withFrameNanos { }
}
val rect =
with(density) {
panelSize.panelVisibilityRect(keyboardClearancePx = keyboardClearance.toPx())
}
panelBringIntoViewRequester.bringIntoView(rect)
withFrameNanos { }
panelBringIntoViewRequester.bringIntoView(rect)
}
Column(
modifier =
Modifier
.fillMaxWidth()
.bringIntoViewRequester(panelBringIntoViewRequester)
.onSizeChanged { panelSize = it }
.clip(shape)
.background(colors.surface)
.border(width = 1.dp, color = colors.borderCard, shape = shape)
.padding(RecipeTheme.spacing.sm),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
SearchRow(
query = query,
onQueryChange = onSetQuery,
onCancel = onClose,
focusRequester = focusRequester,
)
if (results.isEmpty()) {
EmptyResultsMessage()
} else {
ResultsList(results = results, onPick = onPick)
}
}
}
@Composable
private fun SearchRow(
query: String,
onQueryChange: (String) -> Unit,
onCancel: () -> Unit,
focusRequester: FocusRequester,
) {
val colors = RecipeTheme.colors
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
SearchInputField(
value = query,
onValueChange = onQueryChange,
focusRequester = focusRequester,
modifier = Modifier.weight(1f),
)
UnstyledButton(
onClick = onCancel,
backgroundColor = Color.Transparent,
contentColor = colors.contentMuted,
contentPadding = PaddingValues(horizontal = RecipeTheme.spacing.sm),
) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_add_ingredient_cancel),
style =
RecipeTheme.typography.label.copy(
color = colors.contentMuted,
fontWeight = FontWeight.SemiBold,
fontSize = CancelTextSize,
),
)
}
}
}
@Composable
private fun SearchInputField(
value: String,
onValueChange: (String) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(SearchInputCornerRadius)
Box(
modifier =
modifier
.height(SearchInputHeight)
.clip(shape)
.background(colors.background)
.border(width = 1.dp, color = colors.borderCard, shape = shape)
.padding(horizontal = RecipeTheme.spacing.sm),
contentAlignment = Alignment.CenterStart,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
UnstyledIcon(
imageVector = Lucide.Search,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(SearchIconSize),
)
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
cursorBrush = SolidColor(colors.accent),
textStyle =
RecipeTheme.typography.body.copy(
color = colors.content,
fontSize = SearchInputTextSize,
),
modifier = Modifier.weight(1f).fillMaxHeight().focusRequester(focusRequester),
decorationBox = { inner ->
Box(
modifier = Modifier.fillMaxHeight().fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_add_ingredient_search_placeholder),
style =
RecipeTheme.typography.body.copy(
color = colors.contentMuted,
fontSize = SearchInputTextSize,
),
)
}
inner()
}
},
)
}
}
}
@Composable
private fun ResultsList(
results: List<AddableIngredientUi>,
onPick: (AddableIngredientUi) -> Unit,
) {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(ResultsCardCornerRadius)
val scrollState = rememberScrollState()
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = ResultsListMaxHeight)
.clip(shape)
.background(colors.background)
.border(width = 1.dp, color = colors.borderCard, shape = shape)
.verticalScroll(scrollState),
) {
results.forEachIndexed { index, ingredient ->
if (index > 0) IngredientDivider()
ResultRow(ingredient = ingredient, onClick = { onPick(ingredient) })
}
}
}
@Composable
private fun ResultRow(
ingredient: AddableIngredientUi,
onClick: () -> Unit,
) {
val colors = RecipeTheme.colors
UnstyledButton(
onClick = onClick,
backgroundColor = Color.Transparent,
contentColor = colors.content,
contentPadding =
PaddingValues(
horizontal = ResultRowHorizontalPadding,
vertical = ResultRowVerticalPadding,
),
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = ResultRowMinHeight),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
BasicText(
text = ingredient.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
style =
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = ResultRowTextSize,
),
)
IngredientAmount(amount = ingredient.defaultAmount, unit = ingredient.defaultUnit)
}
}
}
@Composable
private fun EmptyResultsMessage() {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(ResultsCardCornerRadius)
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(shape)
.background(colors.background)
.border(width = 1.dp, color = colors.borderCard, shape = shape)
.padding(vertical = EmptyMessagePadding),
contentAlignment = Alignment.Center,
) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_add_ingredient_empty),
style =
RecipeTheme.typography.label.copy(
color = colors.contentMuted,
fontSize = ResultRowTextSize,
),
)
}
}
private fun filterCatalog(
catalog: List<AddableIngredientUi>,
usedIngredientIds: Set<String>,
query: String,
maxResults: Int,
): List<AddableIngredientUi> {
val needle = query.trim().lowercase()
return catalog.asSequence()
.filter { it.ingredientId !in usedIngredientIds }
.filter { needle.isEmpty() || it.name.lowercase().contains(needle) }
.take(maxResults)
.toList()
}
private fun IntSize.panelVisibilityRect(keyboardClearancePx: Float): Rect =
Rect(
left = 0f,
top = 0f,
right = width.toFloat(),
bottom = height.toFloat() + keyboardClearancePx,
)
private val CollapsedCornerRadius = 12.dp
private val CollapsedVerticalPadding = 10.dp
private val CollapsedIconSize = 12.dp
private val CollapsedTextSize = 12.sp
private val CardCornerRadius = 14.dp
private val CancelTextSize = 11.sp
private const val DefaultKeyboardAnimationDurationMillis = 250
private const val MinPanelAnimationDurationMillis = 120
private val SearchInputHeight = 36.dp
private val SearchInputCornerRadius = 10.dp
private val SearchInputTextSize = 13.sp
private val SearchIconSize = 14.dp
private val ResultsListMaxHeight = 200.dp
// Smaller than IngredientCard's 16dp — nested inside the search card, deserves a tighter corner.
private val ResultsCardCornerRadius = 12.dp
private val ResultRowHorizontalPadding = 12.dp
private val ResultRowVerticalPadding = 8.dp
// Smaller than IngredientRow's 48dp min — these rows show only a name, no swap/amount affordances.
private val ResultRowMinHeight = 40.dp
private val ResultRowTextSize = 12.sp
private val EmptyMessagePadding = 14.dp

View File

@@ -0,0 +1,147 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
import com.composeunstyled.UnstyledButton
import dev.ulfrx.recipe.ui.components.recipe.IngredientCard
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
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_removed_format
import recipe.composeapp.generated.resources.meal_plan_editor_removed_restore
/**
* Wrapping card with one row per visible ingredient — both the recipe's
* (minus excluded) and the user-added ones — plus the "X usuniętych —
* Przywróć" bar appended below the card. Reuses the shared [IngredientRow]
* so the visual language matches the read-only detail screen exactly.
*/
@Composable
internal fun IngredientEditorList(
recipeIngredients: List<RecipeIngredientSlotUi>,
addedIngredients: List<AddedIngredientUi>,
excludedIngredientIds: Set<String>,
substitutions: Map<String, String>,
servings: Int,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
onRemoveRecipeIngredient: (slotId: String) -> Unit,
onRemoveAddedIngredient: (ingredientId: String) -> Unit,
onRestoreRemoved: () -> Unit,
modifier: Modifier = Modifier,
) {
val visibleRecipeIngredients =
remember(recipeIngredients, excludedIngredientIds) {
recipeIngredients.filter { it.id !in excludedIngredientIds }
}
Column(modifier = modifier.fillMaxWidth()) {
IngredientCard {
visibleRecipeIngredients.forEachIndexed { index, slot ->
if (index > 0) IngredientDivider()
val scaledSlot = remember(slot, servings) { slot.scaledBy(servings) }
IngredientRow(
slot = scaledSlot,
selectedOptionId = substitutions[slot.id] ?: slot.default.id,
onSelect =
if (slot.alternatives.isNotEmpty()) {
{ choice -> onSelectSubstitution(slot.id, choice.id) }
} else {
null
},
onRemove = { onRemoveRecipeIngredient(slot.id) },
)
}
addedIngredients.forEachIndexed { index, added ->
if (visibleRecipeIngredients.isNotEmpty() || index > 0) IngredientDivider()
val scaledSlot = remember(added, servings) { added.toScaledSyntheticSlot(servings) }
IngredientRow(
slot = scaledSlot,
addedMarker = true,
onRemove = { onRemoveAddedIngredient(added.ingredientId) },
)
}
}
if (excludedIngredientIds.isNotEmpty()) {
RemovedBar(
count = excludedIngredientIds.size,
onRestore = onRestoreRemoved,
modifier = Modifier.padding(top = RecipeTheme.spacing.sm),
)
}
}
}
@Composable
private fun RemovedBar(
count: Int,
onRestore: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = RemovedBarHorizontalInset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_removed_format, count),
style =
RecipeTheme.typography.label.copy(
color = colors.contentMuted,
fontSize = RemovedBarTextSize,
),
)
UnstyledButton(
onClick = onRestore,
contentColor = colors.content,
backgroundColor = Color.Transparent,
) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_removed_restore),
style =
RecipeTheme.typography.label.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = RemovedBarTextSize,
),
)
}
}
}
private fun AddedIngredientUi.toScaledSyntheticSlot(servings: Int): RecipeIngredientSlotUi =
RecipeIngredientSlotUi(
default =
RecipeIngredientOptionUi(
id = ingredientId,
name = name,
amount = amount * servings,
unit = unit,
),
alternatives = emptyList(),
id = "added:$ingredientId",
)
private val RemovedBarHorizontalInset = 4.dp
private val RemovedBarTextSize = 11.sp

View File

@@ -0,0 +1,245 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.calendar.CalendarPillExpandDirection
import dev.ulfrx.recipe.ui.components.calendar.RecipeCalendarPill
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
import dev.ulfrx.recipe.ui.components.recipe.RecipeServingsStepper
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
import dev.ulfrx.recipe.ui.components.section.SectionTitle
import dev.ulfrx.recipe.ui.keyboard.rememberKeyboardTransitionState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.meal_plan_editor_section_ingredients
import recipe.composeapp.generated.resources.meal_plan_editor_section_servings
import recipe.composeapp.generated.resources.meal_plan_editor_section_slot
import recipe.composeapp.generated.resources.nutrition_label
import recipe.composeapp.generated.resources.recipe_detail_servings_decrement_a11y
import recipe.composeapp.generated.resources.recipe_detail_servings_increment_a11y
/**
* Scrollable body of the meal-plan editor. Stays a pure stateless renderer —
* top floating actions (back, confirm) and the sheet handle live one level up
* inside [RecipeDetailSheet] so both detail and editor content composables can
* share the same chrome.
*/
@Composable
internal fun MealPlanEditorContent(
editing: MealPlanEditorState.Editing,
catalog: List<AddableIngredientUi>,
topChromeInset: Dp,
topChromeHeight: Dp,
onSelectDate: (LocalDate) -> Unit,
onSetCalendarExpanded: (Boolean) -> Unit,
onSelectSlot: (MealSlot) -> Unit,
onSetServings: (Int) -> Unit,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
onRemoveRecipeIngredient: (slotId: String) -> Unit,
onRemoveAddedIngredient: (ingredientId: String) -> Unit,
onRestoreRemoved: () -> Unit,
onAddIngredient: (AddableIngredientUi) -> Unit,
modifier: Modifier = Modifier,
) {
val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState()
var addPanelOpen by rememberSaveable { mutableStateOf(false) }
val navigationInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val keyboardTransition = rememberKeyboardTransitionState()
val keyboardReserve =
when {
addPanelOpen -> maxOf(keyboardTransition.currentInset, keyboardTransition.targetInset)
keyboardTransition.currentInset > navigationInset -> keyboardTransition.currentInset
else -> 0.dp
}
val bottomInset = maxOf(navigationInset, keyboardReserve)
val scaledNutrition =
remember(editing.recipe.nutrition, editing.servings) {
editing.recipe.nutrition.scaledBy(editing.servings)
}
val usedIngredientIds =
remember(editing.addedIngredients) {
editing.addedIngredients.mapTo(mutableSetOf()) { it.ingredientId }
}
Column(
modifier =
modifier
.fillMaxSize()
.verticalScroll(scrollState, enabled = !addPanelOpen),
) {
Spacer(Modifier.height(topChromeInset))
// Title sits in a row whose vertical bounds match the floating chrome
// (back/add buttons) so at scroll=0 it reads as the centre of the
// toolbar. Horizontal inset clears the chrome buttons — they are
// square pills of [topChromeHeight] anchored at spacing.lg.
Box(
modifier =
Modifier
.fillMaxWidth()
.height(topChromeHeight)
.padding(horizontal = spacing.lg + topChromeHeight + spacing.sm),
contentAlignment = Alignment.Center,
) {
RecipeTitle(title = editing.recipe.title)
}
Spacer(Modifier.height(spacing.xl))
RecipeCalendarPill(
selectedDate = editing.selectedDate,
expanded = editing.calendarExpanded,
onExpandedChange = onSetCalendarExpanded,
onSelectDate = onSelectDate,
onSelectionShift = onSelectDate,
expandDirection = CalendarPillExpandDirection.Down,
glass = false,
tint = RecipeTheme.colors.surface,
modifier = Modifier.padding(horizontal = spacing.lg),
)
SectionContainer {
SectionTitle(text = stringResource(Res.string.meal_plan_editor_section_slot))
Spacer(Modifier.height(spacing.sm))
MealSlotChipsRow(
allSlots = MealSlot.entries,
allowedSlots = editing.recipe.allowedSlots,
selectedSlot = editing.selectedSlot,
onSelectSlot = onSelectSlot,
)
}
SectionContainer {
SectionTitle(text = stringResource(Res.string.nutrition_label))
Spacer(Modifier.height(spacing.sm))
NutritionSummary(
nutrition = scaledNutrition,
modifier = Modifier.fillMaxWidth(),
)
}
SectionContainer {
ServingsRow(
servings = editing.servings,
onServingsChange = onSetServings,
)
}
SectionContainer {
SectionTitle(text = stringResource(Res.string.meal_plan_editor_section_ingredients))
Spacer(Modifier.height(spacing.sm))
IngredientEditorList(
recipeIngredients = editing.recipe.ingredients,
addedIngredients = editing.addedIngredients,
excludedIngredientIds = editing.excludedIngredients,
substitutions = editing.substitutions,
servings = editing.servings,
onSelectSubstitution = onSelectSubstitution,
onRemoveRecipeIngredient = onRemoveRecipeIngredient,
onRemoveAddedIngredient = onRemoveAddedIngredient,
onRestoreRemoved = onRestoreRemoved,
)
Spacer(Modifier.height(spacing.sm))
AddIngredientPanel(
catalog = catalog,
usedIngredientIds = usedIngredientIds,
onPick = onAddIngredient,
keyboardClearance = keyboardReserve + spacing.sm,
autoFocusEnabled = addPanelOpen,
keyboardAnimationDurationMillis = keyboardTransition.animationDurationMillis,
onOpenChange = { addPanelOpen = it },
)
}
Spacer(Modifier.height(bottomInset + spacing.xxl))
}
}
@Composable
private fun ServingsRow(
servings: Int,
onServingsChange: (Int) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SectionTitle(text = stringResource(Res.string.meal_plan_editor_section_servings))
RecipeServingsStepper(
servings = servings,
servingsRange = MIN_PLAN_SERVINGS..MAX_PLAN_SERVINGS,
decrementContentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
incrementContentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
onServingsChange = onServingsChange,
)
}
}
@Composable
private fun SectionContainer(content: @Composable () -> Unit) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
top = RecipeTheme.spacing.xl,
),
) {
content()
}
}
@Composable
private fun RecipeTitle(
title: String,
modifier: Modifier = Modifier,
) {
BasicText(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style =
RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = RecipeTitleSize,
textAlign = TextAlign.Center,
),
modifier = modifier,
)
}
private val RecipeTitleSize = 14.sp

View File

@@ -0,0 +1,23 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
import kotlinx.datetime.LocalDate
internal const val MIN_PLAN_SERVINGS = 1
internal const val MAX_PLAN_SERVINGS = 12
sealed interface MealPlanEditorState {
data object Hidden : MealPlanEditorState
data class Editing(
val recipe: RecipeDetailUi,
val selectedDate: LocalDate,
val selectedSlot: MealSlot,
val calendarExpanded: Boolean = false,
val servings: Int = MIN_PLAN_SERVINGS,
val substitutions: Map<String, String> = emptyMap(),
val excludedIngredients: Set<String> = emptySet(),
val addedIngredients: List<AddedIngredientUi> = emptyList(),
) : MealPlanEditorState
}

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import kotlinx.datetime.LocalDate
/**
* Ingredient appended to a recipe inside the editor (not part of the recipe's
* original ingredient list). Removed by id — never deduped into the recipe's
* exclusion set.
*/
data class AddedIngredientUi(
val ingredientId: String,
val name: String,
val amount: Double,
val unit: String,
)
/** Catalog entry shown in the "Dodaj składnik" search panel. */
data class AddableIngredientUi(
val ingredientId: String,
val name: String,
val defaultAmount: Double,
val defaultUnit: String,
)
/**
* Payload emitted by [MealPlanEditorViewModel.confirm] when the user adds the
* meal to the plan. Persistence to `planStore` and the sync engine lands in
* Phase 6+; the editor itself produces this value-type only.
*/
data class PlannedMealUi(
val recipeId: String,
val date: LocalDate,
val slot: MealSlot,
val servings: Int,
val substitutions: Map<String, String>,
val excludedIngredients: Set<String>,
val addedIngredients: List<AddedIngredientUi>,
)

View File

@@ -0,0 +1,120 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.options
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
class MealPlanEditorViewModel : ViewModel() {
private val _state = MutableStateFlow<MealPlanEditorState>(MealPlanEditorState.Hidden)
val state: StateFlow<MealPlanEditorState> = _state.asStateFlow()
fun open(
recipe: RecipeDetailUi,
initialSubstitutions: Map<String, String> = emptyMap(),
initialServings: Int = MIN_PLAN_SERVINGS,
initialDate: LocalDate = todayInSystemTz(),
) {
val slot = recipe.allowedSlots.firstOrNull() ?: MealSlot.entries.first()
_state.value =
MealPlanEditorState.Editing(
recipe = recipe,
selectedDate = initialDate,
selectedSlot = slot,
servings = initialServings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
substitutions = initialSubstitutions.filterValid(recipe),
)
}
fun close() {
_state.value = MealPlanEditorState.Hidden
}
fun confirm(): PlannedMealUi? {
val editing = _state.value as? MealPlanEditorState.Editing ?: return null
_state.value = MealPlanEditorState.Hidden
return PlannedMealUi(
recipeId = editing.recipe.id,
date = editing.selectedDate,
slot = editing.selectedSlot,
servings = editing.servings,
substitutions = editing.substitutions,
excludedIngredients = editing.excludedIngredients,
addedIngredients = editing.addedIngredients,
)
}
fun selectDate(date: LocalDate) =
updateEditing { it.copy(selectedDate = date) }
fun setCalendarExpanded(expanded: Boolean) =
updateEditing { it.copy(calendarExpanded = expanded) }
fun selectSlot(slot: MealSlot) =
updateEditing {
if (slot in it.recipe.allowedSlots) it.copy(selectedSlot = slot) else it
}
fun setServings(value: Int) =
updateEditing { it.copy(servings = value.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS)) }
fun selectSubstitution(
slotId: String,
optionId: String,
) = updateEditing { editing ->
val slot = editing.recipe.ingredients.firstOrNull { it.id == slotId } ?: return@updateEditing editing
if (slot.options.none { it.id == optionId }) return@updateEditing editing
val substitutions =
if (optionId == slot.default.id) {
editing.substitutions - slotId
} else {
editing.substitutions + (slotId to optionId)
}
editing.copy(substitutions = substitutions)
}
fun removeRecipeIngredient(slotId: String) =
updateEditing { it.copy(excludedIngredients = it.excludedIngredients + slotId) }
fun restoreRemovedIngredients() =
updateEditing { it.copy(excludedIngredients = emptySet()) }
fun addIngredient(ingredient: AddableIngredientUi) =
updateEditing { editing ->
if (editing.addedIngredients.any { it.ingredientId == ingredient.ingredientId }) {
editing
} else {
editing.copy(addedIngredients = editing.addedIngredients + ingredient.toAdded())
}
}
fun removeAddedIngredient(ingredientId: String) =
updateEditing { it.copy(addedIngredients = it.addedIngredients.filterNot { added -> added.ingredientId == ingredientId }) }
private inline fun updateEditing(crossinline transform: (MealPlanEditorState.Editing) -> MealPlanEditorState.Editing) {
_state.update { current ->
if (current is MealPlanEditorState.Editing) transform(current) else current
}
}
private fun Map<String, String>.filterValid(recipe: RecipeDetailUi): Map<String, String> =
filter { (slotId, optionId) ->
val slot = recipe.ingredients.firstOrNull { it.id == slotId }
slot != null && slot.options.any { it.id == optionId }
}
private fun AddableIngredientUi.toAdded() =
AddedIngredientUi(
ingredientId = ingredientId,
name = name,
amount = defaultAmount,
unit = defaultUnit,
)
}

View File

@@ -0,0 +1,40 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.ulfrx.recipe.ui.components.chips.MealSlotChip
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
/**
* Renders every meal slot as a chip; slots outside [allowedSlots] are visible
* but disabled (recipe-specific availability signal). Selection is single-pick.
*/
@Composable
internal fun MealSlotChipsRow(
allSlots: List<MealSlot>,
allowedSlots: List<MealSlot>,
selectedSlot: MealSlot,
onSelectSlot: (MealSlot) -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
allSlots.forEach { slot ->
val enabled = slot in allowedSlots
MealSlotChip(
label = stringResource(slot.labelRes),
selected = slot == selectedSlot,
enabled = enabled,
onClick = { onSelectSlot(slot) },
)
}
}
}

View File

@@ -0,0 +1,51 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
/**
* UI-only stand-in for the future ingredient catalog (Phase 8 pantry +
* Phase 6 planner reach into the real INGREDIENTS index). Names match the
* pool used by sample recipes so the search panel feels populated.
*/
internal val sampleAddableIngredients: List<AddableIngredientUi> =
listOf(
addable("ing_cynamon", "Cynamon", 1.0, "łyżeczka"),
addable("ing_jogurt", "Jogurt naturalny", 100.0, "g"),
addable("ing_maslo_orzechowe", "Masło orzechowe", 15.0, "g"),
addable("ing_rodzynki", "Rodzynki", 15.0, "g"),
addable("ing_kakao", "Kakao", 5.0, "g"),
addable("ing_nasiona_chia", "Nasiona chia", 1.0, "łyżka"),
addable("ing_siemie_lniane", "Siemię lniane", 1.0, "łyżka"),
addable("ing_orzechy_nerkowca", "Orzechy nerkowca", 15.0, "g"),
addable("ing_pestki_dyni", "Pestki dyni", 10.0, "g"),
addable("ing_pestki_slonecznika", "Pestki słonecznika", 10.0, "g"),
addable("ing_daktyle", "Daktyle suszone", 20.0, "g"),
addable("ing_kokos_wiorki", "Wiórki kokosowe", 10.0, "g"),
addable("ing_imbir", "Imbir świeży", 5.0, "g"),
addable("ing_kurkuma", "Kurkuma", 1.0, "łyżeczka"),
addable("ing_papryka_slodka", "Papryka słodka", 1.0, "łyżeczka"),
addable("ing_oliwa", "Oliwa", 10.0, "ml"),
addable("ing_oct_balsamiczny", "Ocet balsamiczny", 5.0, "ml"),
addable("ing_musztarda", "Musztarda", 5.0, "g"),
addable("ing_majeranek", "Majeranek", 1.0, "łyżeczka"),
addable("ing_oregano", "Oregano", 1.0, "łyżeczka"),
addable("ing_bazylia", "Bazylia świeża", 5.0, "g"),
addable("ing_pietruszka_nat", "Natka pietruszki", 5.0, "g"),
addable("ing_kapary", "Kapary", 10.0, "g"),
addable("ing_oliwki_zielone", "Oliwki zielone", 30.0, "g"),
addable("ing_pomidorki_koktajlowe", "Pomidorki koktajlowe", 80.0, "g"),
addable("ing_rukola", "Rukola", 20.0, "g"),
addable("ing_szpinak_baby", "Szpinak baby", 30.0, "g"),
addable("ing_quinoa", "Komosa ryżowa", 60.0, "g"),
addable("ing_kasza_gryczana", "Kasza gryczana", 60.0, "g"),
)
private fun addable(
id: String,
name: String,
amount: Double,
unit: String,
) = AddableIngredientUi(
ingredientId = id,
name = name,
defaultAmount = amount,
defaultUnit = unit,
)

View File

@@ -1,62 +1,41 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import dev.ulfrx.recipe.ui.components.calendar.CalendarLocale
import dev.ulfrx.recipe.ui.components.calendar.CalendarPill
import dev.ulfrx.recipe.ui.components.calendar.DayState
import dev.ulfrx.recipe.ui.components.calendar.RecipeCalendarPill
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
/**
* Planner-screen flavour of [RecipeCalendarPill] — supplies the dummy
* "you already have something planned" indicators that will be replaced by
* real planner data in Phase 6.
*/
@Composable
fun PlannerCalendarPill(
selectedDate: LocalDate,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
onShiftSelection: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val today = remember { todayInSystemTz() }
val locale = CalendarLocale.PL
val plannedDummy =
remember(today) {
remember {
val today = todayInSystemTz()
setOf(today, today.plus(DatePeriod(days = 1)), today.plus(DatePeriod(days = 3)))
}
val dayState =
remember(plannedDummy) {
{ date: LocalDate -> DayState(indicator = date in plannedDummy) }
}
val pillTextStyle = RecipeTheme.typography.label.copy(fontWeight = FontWeight.Light, fontSize = 12.sp)
CalendarPill(
RecipeCalendarPill(
selectedDate = selectedDate,
expanded = expanded,
onExpandedChange = onExpandedChange,
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate,
collapsedContent = {
PlannerWeekStrip(
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate,
numberStyle = pillTextStyle,
dayState = dayState,
modifier = Modifier.weight(1f),
)
BasicText(
text = locale.monthsShort[selectedDate.monthNumber - 1],
style = pillTextStyle.copy(color = RecipeTheme.colors.contentMuted),
)
},
dayState = dayState,
modifier = modifier.fillMaxWidth(),
onSelectionShift = onShiftSelection,
plannedDates = plannedDummy,
modifier = modifier,
)
}

View File

@@ -38,6 +38,7 @@ fun PlannerScreen(viewModel: PlannerViewModel) {
expanded = state.isCalendarOpen,
onExpandedChange = viewModel::setCalendarOpen,
onSelectDate = viewModel::selectDate,
onShiftSelection = viewModel::shiftSelection,
)
},
) {

View File

@@ -18,7 +18,16 @@ class PlannerViewModel : ViewModel() {
val state: StateFlow<PlannerState> = _state.asStateFlow()
fun selectDate(date: LocalDate) {
_state.update { it.copy(selectedDate = date, isCalendarOpen = false) }
_state.update { it.copy(selectedDate = date) }
}
/**
* Move the highlighted day without collapsing the calendar pill. Used by
* the collapsed strip's week-paged swipe gesture so swipe-to-shift doesn't
* also dismiss the calendar.
*/
fun shiftSelection(date: LocalDate) {
_state.update { it.copy(selectedDate = date) }
}
fun setCalendarOpen(open: Boolean) {

View File

@@ -2,169 +2,200 @@ package dev.ulfrx.recipe.ui.screens.recipedetail
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
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.aspectRatio
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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
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.UnstyledButton
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.recipe_detail_plan_button
import recipe.composeapp.generated.resources.sample_recipe
@Composable
internal fun RecipeDetailHero(
title: String,
cookingMinutes: Int,
servings: Int,
onServingsChange: (Int) -> Unit,
onPlanClick: () -> 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,
),
Column(
modifier =
modifier
.fillMaxWidth()
.padding(
top = HERO_TOP_PADDING,
bottom = spacing.lg,
start = spacing.lg,
end = spacing.lg,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(Res.drawable.sample_recipe),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(BANNER_ASPECT_RATIO)
.shadow(
elevation = BANNER_SHADOW_ELEVATION,
shape = RoundedCornerShape(BANNER_CORNER),
ambientColor = BANNER_SHADOW_COLOR,
spotColor = BANNER_SHADOW_COLOR,
)
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,
)
}
}
.clip(RoundedCornerShape(BANNER_CORNER)),
)
Spacer(Modifier.height(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.Center,
),
)
Spacer(Modifier.height(spacing.lg))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
MetaChip(
icon = Lucide.Clock,
text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
)
}
PlanButton(
text = stringResource(Res.string.recipe_detail_plan_button),
onClick = onPlanClick,
)
}
}
}
@Composable
private fun MetaChip(
icon: ImageVector,
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
.clip(CHIP_SHAPE)
.background(colors.surface)
.border(1.dp, colors.separator, CHIP_SHAPE)
.padding(horizontal = CHIP_PADDING_H, vertical = CHIP_PADDING_V),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(CHIP_ICON_GAP),
) {
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,
),
)
}
UnstyledIcon(
imageVector = icon,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(CHIP_ICON_SIZE),
)
BasicText(
text = text,
style = RecipeTheme.typography.label.copy(color = colors.contentMuted),
)
}
}
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
@Composable
private fun PlanButton(
text: String,
onClick: () -> Unit,
) {
val colors = RecipeTheme.colors
UnstyledButton(
onClick = onClick,
shape = CHIP_SHAPE,
backgroundColor = colors.accent,
contentColor = colors.surface,
contentPadding = PaddingValues(horizontal = PLAN_PADDING_H, vertical = PLAN_PADDING_V),
) {
UnstyledIcon(
imageVector = Lucide.Calendar,
contentDescription = null,
tint = colors.surface,
modifier = Modifier.size(CHIP_ICON_SIZE),
)
Spacer(Modifier.width(CHIP_ICON_GAP))
BasicText(
text = text,
style =
RecipeTheme.typography.label.copy(
color = colors.surface,
fontWeight = FontWeight.Bold,
),
)
}
}
private val CHIP_CORNER_RADIUS = 14.dp
private val CHIP_PADDING_H = 10.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 = 24.sp
private val TITLE_LINE_HEIGHT = 28.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_GAP = 5.dp
private val CHIP_ICON_SIZE = 11.dp
private val CHIP_TEXT_SIZE = 11.sp
private val CHIP_ICON_SIZE = 14.dp
private val CHIP_ICON_GAP = 5.dp
private val TITLE_TEXT_SIZE = 17.sp
private val TITLE_LINE_HEIGHT = 21.sp
// Plan button is slightly more padded than meta chips so it reads as a CTA, not just a coloured chip.
private val PLAN_PADDING_H = 14.dp
private val PLAN_PADDING_V = 9.dp

View File

@@ -8,7 +8,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -19,7 +18,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -46,55 +44,76 @@ import com.composables.core.Scrim
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.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import com.composables.icons.lucide.Plus
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
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.IngredientCard
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
import dev.ulfrx.recipe.ui.components.section.Section
import dev.ulfrx.recipe.ui.components.section.SectionTitle
import dev.ulfrx.recipe.ui.components.recipe.RecipeServingsStepper
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
import dev.ulfrx.recipe.ui.screens.mealplaneditor.AddableIngredientUi
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorContent
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorState
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.mealplaneditor.PlannedMealUi
import dev.ulfrx.recipe.ui.screens.mealplaneditor.sampleAddableIngredients
import kotlinx.datetime.LocalDate
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_back_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_confirm_a11y
import recipe.composeapp.generated.resources.nutrition_label
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
@Composable
fun RecipeDetailSheet(
viewModel: RecipeDetailViewModel,
onPlanRecipe: (recipeId: String) -> Unit,
detailViewModel: RecipeDetailViewModel,
editorViewModel: MealPlanEditorViewModel,
onPlanConfirmed: (PlannedMealUi) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val detailState by detailViewModel.state.collectAsStateWithLifecycle()
val editorState by editorViewModel.state.collectAsStateWithLifecycle()
val ready = detailState as? RecipeDetailState.Ready
val editing = editorState as? MealPlanEditorState.Editing
val anyOpen = ready != null || editing != null
val sheetState =
rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
)
val ready = state as? RecipeDetailState.Ready
val hasReadyRecipe = ready != null
LaunchedEffect(hasReadyRecipe) {
sheetState.targetDetent = if (hasReadyRecipe) SheetDetent.FullyExpanded else SheetDetent.Hidden
LaunchedEffect(anyOpen) {
sheetState.targetDetent = if (anyOpen) 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.
// Only caller of the dismiss path: a drag that settles the sheet at Hidden
// while either VM still holds state. Programmatic closes must set
// targetDetent = Hidden and let this fire — calling dismiss() directly
// would clear the recipe mid-animation and blank the closing sheet.
LaunchedEffect(sheetState.isIdle, sheetState.currentDetent) {
if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && hasReadyRecipe) {
viewModel.dismiss()
if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && anyOpen) {
editorViewModel.close()
detailViewModel.dismiss()
}
}
@@ -109,114 +128,202 @@ fun RecipeDetailSheet(
backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
) {
ready?.let {
RecipeDetailContent(
ready = it,
onServingsChange = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
onPlanRecipe = onPlanRecipe,
)
}
SheetBody(
editing = editing,
ready = ready,
onOpenEditor = {
val current = ready ?: return@SheetBody
editorViewModel.open(
recipe = current.recipe,
initialSubstitutions = current.substitutions,
initialServings = current.servings,
)
},
onCloseEditor = editorViewModel::close,
onConfirmEditor = {
val planned = editorViewModel.confirm() ?: return@SheetBody
onPlanConfirmed(planned)
sheetState.targetDetent = SheetDetent.Hidden
},
detailActions =
RecipeDetailActions(
onServingsChange = detailViewModel::setServings,
onSelectSubstitution = detailViewModel::selectSubstitution,
),
editorActions =
EditorActions(
onSelectDate = editorViewModel::selectDate,
onSetCalendarExpanded = editorViewModel::setCalendarExpanded,
onSelectSlot = editorViewModel::selectSlot,
onSetServings = editorViewModel::setServings,
onSelectSubstitution = editorViewModel::selectSubstitution,
onRemoveRecipeIngredient = editorViewModel::removeRecipeIngredient,
onRemoveAddedIngredient = editorViewModel::removeAddedIngredient,
onRestoreRemoved = editorViewModel::restoreRemovedIngredients,
onAddIngredient = editorViewModel::addIngredient,
),
)
}
}
}
@Composable
private fun BottomSheetScope.RecipeDetailContent(
ready: RecipeDetailState.Ready,
onServingsChange: (Int) -> Unit,
onSelectSubstitution: (String, String) -> Unit,
onPlanRecipe: (String) -> Unit,
private fun BottomSheetScope.SheetBody(
editing: MealPlanEditorState.Editing?,
ready: RecipeDetailState.Ready?,
onOpenEditor: () -> Unit,
onCloseEditor: () -> Unit,
onConfirmEditor: () -> Unit,
detailActions: RecipeDetailActions,
editorActions: EditorActions,
) {
val colors = RecipeTheme.colors
val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState()
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val handleLabel = stringResource(Res.string.recipe_detail_handle_a11y)
val backdrop = rememberGlassBackdropState()
val detail = ready.recipe
val servings = ready.servings
val handleLabel = stringResource(Res.string.recipe_detail_handle_a11y)
val spacing = RecipeTheme.spacing
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
RecipeDetailHero(
title = detail.title,
cookingMinutes = detail.cookingMinutes,
servings = servings,
onServingsChange = onServingsChange,
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
Spacer(Modifier.height(spacing.xl))
NutritionSection(nutrition = detail.nutrition.scaledBy(servings))
Spacer(Modifier.height(spacing.xl))
IngredientsSection(
ingredients = detail.ingredients,
servings = servings,
substitutions = ready.substitutions,
onSelectSubstitution = onSelectSubstitution,
when {
editing != null ->
MealPlanEditorContent(
editing = editing,
catalog = sampleAddableIngredients,
topChromeInset = TopActionsTopInset,
topChromeHeight = TopPillHeight,
onSelectDate = editorActions.onSelectDate,
onSetCalendarExpanded = editorActions.onSetCalendarExpanded,
onSelectSlot = editorActions.onSelectSlot,
onSetServings = editorActions.onSetServings,
onSelectSubstitution = editorActions.onSelectSubstitution,
onRemoveRecipeIngredient = editorActions.onRemoveRecipeIngredient,
onRemoveAddedIngredient = editorActions.onRemoveAddedIngredient,
onRestoreRemoved = editorActions.onRestoreRemoved,
onAddIngredient = editorActions.onAddIngredient,
)
Spacer(Modifier.height(spacing.xl))
StepsSection(steps = detail.steps)
Spacer(Modifier.height(bottomInset + spacing.xxl))
}
ready != null ->
RecipeDetailBody(
ready = ready,
onPlanClick = onOpenEditor,
onServingsChange = detailActions.onServingsChange,
onSelectSubstitution = detailActions.onSelectSubstitution,
)
}
}
DragIndication(
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = spacing.sm)
.semantics { contentDescription = handleLabel }
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface.copy(alpha = 0.85f))
.width(HANDLE_WIDTH)
.height(HANDLE_HEIGHT),
SheetHandle(
contentDescription = handleLabel,
modifier = Modifier.align(Alignment.TopCenter).padding(top = spacing.sm),
)
PlanButton(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = spacing.xl, end = spacing.lg),
onClick = { onPlanRecipe(detail.id) },
)
if (editing != null) {
EditorTopActions(
onBack = onCloseEditor,
onConfirm = onConfirmEditor,
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
)
}
}
}
}
@Composable
private fun Section(
title: String,
content: @Composable () -> Unit,
private fun EditorTopActions(
onBack: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
SectionTitle(text = title)
Spacer(Modifier.height(RecipeTheme.spacing.lg))
content()
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CircleGlassButton(
onClick = onBack,
icon = Lucide.ArrowLeft,
contentDescription = stringResource(Res.string.meal_plan_editor_back_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
)
CircleGlassButton(
onClick = onConfirm,
icon = Lucide.Plus,
contentDescription = stringResource(Res.string.meal_plan_editor_confirm_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
)
}
}
@Composable
private fun SectionTitle(text: String) {
BasicText(
text = text.uppercase(),
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
fontSize = SECTION_HEADER_TEXT_SIZE,
letterSpacing = SECTION_HEADER_TRACKING,
fontWeight = FontWeight.Bold,
),
private fun BottomSheetScope.SheetHandle(
contentDescription: String,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
DragIndication(
modifier =
modifier
.semantics { this.contentDescription = contentDescription }
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface.copy(alpha = HandleAlpha))
.width(HandleWidth)
.height(HandleHeight),
)
}
@Composable
private fun RecipeDetailBody(
ready: RecipeDetailState.Ready,
onPlanClick: () -> Unit,
onServingsChange: (Int) -> Unit,
onSelectSubstitution: (String, String) -> Unit,
) {
val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState()
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val detail = ready.recipe
val servings = ready.servings
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
RecipeDetailHero(
title = detail.title,
cookingMinutes = detail.cookingMinutes,
onPlanClick = onPlanClick,
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
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,
servings = servings,
substitutions = ready.substitutions,
onSelectSubstitution = onSelectSubstitution,
)
Spacer(Modifier.height(spacing.xl))
StepsSection(steps = detail.steps)
Spacer(Modifier.height(bottomInset + spacing.xxl))
}
}
}
@Composable
private fun NutritionSection(nutrition: RecipeNutritionUi) {
Section(title = stringResource(Res.string.nutrition_label)) {
@@ -224,6 +331,27 @@ 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))
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 IngredientsSection(
ingredients: List<RecipeIngredientSlotUi>,
@@ -231,17 +359,8 @@ private fun IngredientsSection(
substitutions: Map<String, String>,
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(
modifier =
Modifier
.fillMaxWidth()
.clip(cardShape)
.background(colors.surface)
.border(width = CARD_BORDER_WIDTH, color = colors.borderCard, shape = cardShape),
) {
IngredientCard {
ingredients.forEachIndexed { index, slot ->
if (index > 0) IngredientDivider()
IngredientRow(
@@ -259,18 +378,6 @@ 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<String>) {
Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
@@ -295,9 +402,9 @@ private fun StepRow(
RecipeTheme.typography.body.copy(
color = colors.contentMuted,
fontWeight = FontWeight.Bold,
fontSize = STEP_NUMBER_TEXT_SIZE,
fontSize = StepNumberTextSize,
),
modifier = Modifier.width(STEP_NUMBER_WIDTH),
modifier = Modifier.width(StepNumberWidth),
)
BasicText(
text = text,
@@ -305,72 +412,45 @@ private fun StepRow(
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Normal,
fontSize = STEP_TEXT_SIZE,
lineHeight = STEP_LINE_HEIGHT,
fontSize = StepTextSize,
lineHeight = StepLineHeight,
),
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun PlanButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
GlassSurface(
modifier = modifier.height(PLAN_BUTTON_HEIGHT),
cornerRadius = PLAN_BUTTON_HEIGHT / 2,
tint = colors.surfaceGlass,
) {
UnstyledButton(
onClick = onClick,
backgroundColor = Color.Transparent,
contentColor = colors.content,
contentPadding = PaddingValues(horizontal = RecipeTheme.spacing.lg),
modifier = Modifier.fillMaxHeight(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
UnstyledIcon(
imageVector = Lucide.Calendar,
contentDescription = null,
tint = colors.content,
modifier = Modifier.size(PLAN_BUTTON_ICON_SIZE),
)
BasicText(
text = stringResource(Res.string.recipe_detail_plan_button),
style =
RecipeTheme.typography.label.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
),
)
}
}
}
}
private class RecipeDetailActions(
val onServingsChange: (Int) -> Unit,
val onSelectSubstitution: (String, String) -> Unit,
)
private class EditorActions(
val onSelectDate: (LocalDate) -> Unit,
val onSetCalendarExpanded: (Boolean) -> Unit,
val onSelectSlot: (MealSlot) -> Unit,
val onSetServings: (Int) -> Unit,
val onSelectSubstitution: (String, String) -> Unit,
val onRemoveRecipeIngredient: (String) -> Unit,
val onRemoveAddedIngredient: (String) -> Unit,
val onRestoreRemoved: () -> Unit,
val onAddIngredient: (AddableIngredientUi) -> Unit,
)
private const val SHEET_HEIGHT_FRACTION = 0.92f
private const val SCRIM_FADE_MILLIS = 250
private const val HandleAlpha = 0.85f
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
private val SHEET_CORNER_RADIUS = 28.dp
private val HANDLE_WIDTH = 36.dp
private val HANDLE_HEIGHT = 5.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 HandleWidth = 36.dp
private val HandleHeight = 5.dp
private val StepNumberWidth = 20.dp
private val TopPillHeight = 44.dp
private val TopActionIconSize = 18.dp
private val TopActionsTopInset = 28.dp
private val SECTION_HEADER_TEXT_SIZE = 11.sp
private val SECTION_HEADER_TRACKING = 1.sp
private val STEP_NUMBER_TEXT_SIZE = 11.sp
private val STEP_TEXT_SIZE = 13.sp
private val STEP_LINE_HEIGHT = 19.sp
private val StepNumberTextSize = 11.sp
private val StepTextSize = 13.sp
private val StepLineHeight = 19.sp

View File

@@ -1,5 +1,6 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
@@ -10,4 +11,5 @@ data class RecipeDetailUi(
val nutrition: RecipeNutritionUi,
val ingredients: List<RecipeIngredientSlotUi>,
val steps: List<String>,
val allowedSlots: List<MealSlot>,
)

View File

@@ -1,9 +1,14 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
private val LunchOrDinner = listOf(MealSlot.Lunch, MealSlot.Dinner)
private val BreakfastOrSnack = listOf(MealSlot.Breakfast, MealSlot.Snack)
private val LightMeal = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper)
internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
listOf(
RecipeDetailUi(
@@ -11,6 +16,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Naleśniki z twarogiem",
cookingMinutes = 25,
nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
ingredients =
listOf(
slot("Mąka pszenna", 60.0, "g"),
@@ -39,6 +45,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Owsianka z owocami i orzechami",
cookingMinutes = 10,
nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38),
allowedSlots = BreakfastOrSnack,
ingredients =
listOf(
slot("Płatki owsiane", 50.0, "g"),
@@ -79,6 +86,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Spaghetti bolognese",
cookingMinutes = 40,
nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Makaron spaghetti", 100.0, "g"),
@@ -107,6 +115,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Pierogi ruskie",
cookingMinutes = 90,
nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Mąka pszenna", 120.0, "g"),
@@ -130,6 +139,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Kanapka z awokado i jajkiem",
cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Lunch, MealSlot.Supper, MealSlot.Snack),
ingredients =
listOf(
slot("Pieczywo razowe", 1.0, "kromka"),
@@ -151,6 +161,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Schabowy z ziemniakami",
cookingMinutes = 60,
nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Schab", 150.0, "g"),
@@ -174,6 +185,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Sałatka grecka",
cookingMinutes = 15,
nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12),
allowedSlots = LightMeal,
ingredients =
listOf(
slot("Pomidory", 150.0, "g"),
@@ -196,6 +208,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Zupa pomidorowa z ryżem",
cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Passata pomidorowa", 200.0, "ml"),
@@ -217,6 +230,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Kurczak curry z ryżem basmati",
cookingMinutes = 45,
nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Pierś z kurczaka", 150.0, "g"),
@@ -240,6 +254,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Jajecznica na maśle ze szczypiorkiem",
cookingMinutes = 8,
nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
ingredients =
listOf(
slot("Jajka", 3.0, "szt."),
@@ -259,6 +274,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Risotto z grzybami leśnymi",
cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Ryż arborio", 80.0, "g"),
@@ -281,6 +297,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Tortilla z kurczakiem i warzywami",
cookingMinutes = 20,
nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48),
allowedSlots = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper),
ingredients =
listOf(
slot("Tortilla pszenna", 1.0, "szt."),
@@ -303,6 +320,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Smoothie bananowo-szpinakowe",
cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33),
allowedSlots = BreakfastOrSnack,
ingredients =
listOf(
slot("Banan", 1.0, "szt."),
@@ -328,6 +346,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Łosoś pieczony z brokułami",
cookingMinutes = 30,
nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Filet z łososia", 150.0, "g"),
@@ -349,6 +368,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
title = "Papryki nadziewane kaszą i warzywami",
cookingMinutes = 55,
nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Papryka", 2.0, "szt."),

View File

@@ -15,6 +15,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.mealplaneditor.PlannedMealUi
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
@@ -30,8 +32,9 @@ fun SearchScreen(
viewModel: ShellSearchViewModel,
catalogViewModel: RecipeCatalogViewModel,
detailViewModel: RecipeDetailViewModel,
editorViewModel: MealPlanEditorViewModel,
catalogGridState: LazyGridState,
onPlanRecipe: (String) -> Unit = {},
onPlanConfirmed: (PlannedMealUi) -> Unit = {},
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
@@ -66,8 +69,9 @@ fun SearchScreen(
}
RecipeDetailSheet(
viewModel = detailViewModel,
onPlanRecipe = onPlanRecipe,
detailViewModel = detailViewModel,
editorViewModel = editorViewModel,
onPlanConfirmed = onPlanConfirmed,
)
}
}

View File

@@ -25,6 +25,7 @@ import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
@@ -39,6 +40,7 @@ fun AppShell(modifier: Modifier = Modifier) {
val searchVm: ShellSearchViewModel = koinViewModel()
val catalogVm: RecipeCatalogViewModel = koinViewModel()
val detailVm: RecipeDetailViewModel = koinViewModel()
val editorVm: MealPlanEditorViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState()
@@ -72,6 +74,7 @@ fun AppShell(modifier: Modifier = Modifier) {
viewModel = searchVm,
catalogViewModel = catalogVm,
detailViewModel = detailVm,
editorViewModel = editorVm,
catalogGridState = catalogGridState,
)
} else {

View File

@@ -96,6 +96,5 @@ internal fun DockPressOverlayLayer(
}.alpha(overlayAlpha),
cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress,
tint = RecipeTheme.colors.surfaceGlassOverlay,
) {}
}

View File

@@ -94,7 +94,7 @@ private fun DockTabItem(
) {
val label = stringResource(destination.labelRes)
val a11yLabel = if (isActive) "$label, aktywna" else label
val tint = RecipeTheme.colors.content
val tint = if(isActive) RecipeTheme.colors.accent else RecipeTheme.colors.content
Box(
modifier =
modifier.semantics {

View File

@@ -6,6 +6,7 @@ import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_open_a11y

View File

@@ -37,7 +37,7 @@ fun SearchPill(
UnstyledIcon(
imageVector = Lucide.Search,
contentDescription = null,
tint = RecipeTheme.colors.contentMuted,
tint = RecipeTheme.colors.content,
modifier = Modifier.size(20.dp),
)
},

View File

@@ -2,15 +2,10 @@ package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Semantic color tokens (UI-SPEC § Color, CONTEXT D-14, D-15).
* Values are locked; do not introduce raw hex in screen code.
*/
public data class RecipeColors(
val background: Color,
val surface: Color,
val surfaceGlass: Color,
val surfaceGlassOverlay: Color,
val content: Color,
val contentMuted: Color,
val accent: Color,
@@ -27,8 +22,7 @@ public val LightRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFFEAE6DF),
surface = Color(0xFFFFFFFF),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f),
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.20f),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.6f),
content = Color(0xFF0F1113),
contentMuted = Color(0xFF6B6E73),
accent = Color(0xFFD97757),
@@ -45,11 +39,10 @@ public val DarkRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFF1E2024),
surface = Color(0xFF2A2D31),
surfaceGlass = Color(0xFF494D53).copy(alpha = 0.55f),
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.12f),
surfaceGlass = Color(0xFF313439).copy(alpha = 0.65f),
content = Color(0xFFF1EFEA),
contentMuted = Color(0xFF9AA0A6),
accent = Color(0xFFE48A6E),
accent = Color(0xFFFC8964),
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
separator = Color(0xFF383B40),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),

View File

@@ -1,58 +1,60 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
data object RecipeGlass {
/** Strong refraction tuned for thin chrome elements (dock, pills, search bar). */
val menu: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.10f,
curve = 0.5f,
edge = 0.04f,
dispersion = 0.05f,
saturation = 0.5f,
contrast = 1.3f,
frost = 15.dp,
)
data class RecipeGlass(
val dock: RecipeGlassStyle,
val dockPress: RecipeGlassStyle,
val button: RecipeGlassStyle,
val panel: RecipeGlassStyle,
val chipOnGlass: RecipeGlassStyle,
)
val dockPress: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.05f,
curve = 0.25f,
edge = 0.04f,
fun recipeGlassFor(colors: RecipeColors): RecipeGlass =
RecipeGlass(
dock = RecipeGlassStyle(
refraction = 0.5f,
curve = 0.4f,
edge = 0.03f,
dispersion = 0f,
saturation = 1f,
contrast = 1f,
frost = 2.dp,
tint = colors.surfaceGlass,
),
dockPress = RecipeGlassStyle(
refraction = 0f,
curve = 0f,
edge = 0.03f,
dispersion = 0.0f,
saturation = 1.0f,
contrast = 1.0f,
saturation = 1f,
contrast = 1f,
frost = 0.dp,
)
/** Calm refraction with strong frost — for large surfaces where [menu] would read as a murky lens. */
val panel: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.10f,
curve = 0.3f,
edge = 0.01f,
dispersion = 0.03f,
saturation = 0.5f,
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(
),
button = RecipeGlassStyle(
refraction = 0.5f,
curve = 0.4f,
edge = 0.03f,
dispersion = 0.5f,
saturation = 1f,
contrast = 1f,
frost = 15.dp,
tint = colors.surfaceGlass,
),
panel = RecipeGlassStyle(
refraction = 0f,
curve = 0f,
edge = 0.008f,
dispersion = 0f,
saturation = 1f,
contrast = 1f,
frost = 10.dp,
tint = colors.surfaceGlass,
),
chipOnGlass = RecipeGlassStyle(
refraction = 0f,
curve = 0f,
edge = 0.1f,
@@ -60,8 +62,8 @@ data object RecipeGlass {
saturation = 0.5f,
contrast = 1.5f,
frost = 5.dp,
)
}
),
)
data class RecipeGlassStyle(
val refraction: Float,
@@ -71,19 +73,5 @@ data class RecipeGlassStyle(
val saturation: Float,
val contrast: Float,
val frost: Dp,
val tint: Color? = null,
)
fun lerp(
start: RecipeGlassStyle,
stop: RecipeGlassStyle,
fraction: Float,
): RecipeGlassStyle =
RecipeGlassStyle(
refraction = lerp(start.refraction, stop.refraction, fraction),
curve = lerp(start.curve, stop.curve, fraction),
edge = lerp(start.edge, stop.edge, fraction),
dispersion = lerp(start.dispersion, stop.dispersion, fraction),
saturation = lerp(start.saturation, stop.saturation, fraction),
contrast = lerp(start.contrast, stop.contrast, fraction),
frost = lerp(start.frost.value, stop.frost.value, fraction).dp,
)

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
/**
* Recipe theme entry point (CONTEXT D-14, D-15).
@@ -25,16 +26,21 @@ public val LocalRecipeSpacing: ProvidableCompositionLocal<RecipeSpacing> =
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") }
@Composable
public fun RecipeTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
val recipeGlass = remember(recipeColors) { recipeGlassFor(recipeColors) }
CompositionLocalProvider(
LocalRecipeColors provides recipeColors,
LocalRecipeTypography provides DefaultRecipeTypography,
LocalRecipeSpacing provides DefaultRecipeSpacing,
LocalRecipeShapes provides DefaultRecipeShapes,
LocalRecipeGlass provides recipeGlass,
content = content,
)
}
@@ -57,5 +63,6 @@ object RecipeTheme {
get() = LocalRecipeShapes.current
val glass: RecipeGlass
get() = RecipeGlass
@Composable @ReadOnlyComposable
get() = LocalRecipeGlass.current
}

View File

@@ -0,0 +1,245 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailUi
import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class MealPlanEditorViewModelTest {
@Test
fun opensWithDefaultsDerivedFromRecipe() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe(allowedSlots = listOf(MealSlot.Lunch, MealSlot.Dinner)))
val state = viewModel.editing()
assertEquals(MealSlot.Lunch, state.selectedSlot)
assertEquals(MIN_PLAN_SERVINGS, state.servings)
assertTrue(state.substitutions.isEmpty())
assertTrue(state.excludedIngredients.isEmpty())
assertTrue(state.addedIngredients.isEmpty())
}
@Test
fun initialSubstitutionsAreSanitizedAgainstRecipe() {
val viewModel = MealPlanEditorViewModel()
val recipe = recipe(allowedSlots = listOf(MealSlot.Breakfast))
val validSlot = recipe.ingredients.first { it.alternatives.isNotEmpty() }
val validOption = validSlot.alternatives.first()
viewModel.open(
recipe = recipe,
initialSubstitutions = mapOf(
validSlot.id to validOption.id,
"unknown-slot" to "anything",
validSlot.id + "-x" to validOption.id,
),
)
val state = viewModel.editing()
assertEquals(mapOf(validSlot.id to validOption.id), state.substitutions)
}
@Test
fun closeResetsStateToHidden() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
viewModel.close()
assertEquals(MealPlanEditorState.Hidden, viewModel.state.value)
}
@Test
fun confirmReturnsPayloadAndClosesEditor() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe(allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Lunch)))
viewModel.setServings(4)
viewModel.selectSlot(MealSlot.Lunch)
viewModel.selectDate(LocalDate(2026, 7, 1))
val payload = viewModel.confirm()
assertNotNull(payload)
assertEquals(4, payload.servings)
assertEquals(MealSlot.Lunch, payload.slot)
assertEquals(LocalDate(2026, 7, 1), payload.date)
assertEquals(MealPlanEditorState.Hidden, viewModel.state.value)
}
@Test
fun confirmReturnsNullWhenHidden() {
val viewModel = MealPlanEditorViewModel()
assertNull(viewModel.confirm())
}
@Test
fun servingsAreClampedToSupportedRange() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
viewModel.setServings(0)
assertEquals(MIN_PLAN_SERVINGS, viewModel.editing().servings)
viewModel.setServings(MAX_PLAN_SERVINGS + 5)
assertEquals(MAX_PLAN_SERVINGS, viewModel.editing().servings)
}
@Test
fun selectSlotIgnoresValuesOutsideAllowedSet() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe(allowedSlots = listOf(MealSlot.Breakfast)))
viewModel.selectSlot(MealSlot.Dinner)
assertEquals(MealSlot.Breakfast, viewModel.editing().selectedSlot)
}
@Test
fun substitutionTogglesByPickingDefaultAgain() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val slot = viewModel.editing().recipe.ingredients.first { it.alternatives.isNotEmpty() }
val alt = slot.alternatives.first()
viewModel.selectSubstitution(slot.id, alt.id)
assertEquals(alt.id, viewModel.editing().substitutions[slot.id])
viewModel.selectSubstitution(slot.id, slot.default.id)
assertFalse(slot.id in viewModel.editing().substitutions)
}
@Test
fun removeAndRestoreCycleClearsExclusions() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val slot = viewModel.editing().recipe.ingredients.first()
viewModel.removeRecipeIngredient(slot.id)
assertTrue(slot.id in viewModel.editing().excludedIngredients)
viewModel.restoreRemovedIngredients()
assertTrue(viewModel.editing().excludedIngredients.isEmpty())
}
@Test
fun addingIngredientAppendsToEditingList() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val candidate = addable("ing_test", "Test składnik")
viewModel.addIngredient(candidate)
val state = viewModel.editing()
assertEquals(1, state.addedIngredients.size)
assertEquals(candidate.ingredientId, state.addedIngredients.first().ingredientId)
}
@Test
fun addingDuplicateIngredientIsIgnored() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
val candidate = addable("ing_test", "Test składnik")
viewModel.addIngredient(candidate)
viewModel.addIngredient(candidate)
assertEquals(1, viewModel.editing().addedIngredients.size)
}
@Test
fun removeAddedDropsByIngredientId() {
val viewModel = MealPlanEditorViewModel()
viewModel.open(recipe = recipe())
viewModel.addIngredient(addable("ing_a", "A"))
viewModel.addIngredient(addable("ing_b", "B"))
viewModel.removeAddedIngredient("ing_a")
val remaining = viewModel.editing().addedIngredients.map { it.ingredientId }
assertEquals(listOf("ing_b"), remaining)
}
private fun MealPlanEditorViewModel.editing(): MealPlanEditorState.Editing {
val state = state.value
assertTrue(state is MealPlanEditorState.Editing)
return state
}
private fun recipe(
allowedSlots: List<MealSlot> = listOf(MealSlot.Breakfast, MealSlot.Snack),
): RecipeDetailUi =
RecipeDetailUi(
id = "test_recipe",
title = "Test recipe",
cookingMinutes = 15,
nutrition = RecipeNutritionUi(kcal = 300, protein = 10, fat = 8, carbs = 40),
allowedSlots = allowedSlots,
steps = listOf("Krok 1.", "Krok 2."),
ingredients =
listOf(
slot(
id = "slot_main",
name = "Płatki",
amount = 60.0,
unit = "g",
alternatives = listOf("Płatki górskie" to 60.0, "Płatki jaglane" to 60.0),
),
slot(
id = "slot_fruit",
name = "Borówki",
amount = 40.0,
unit = "g",
alternatives = listOf("Truskawki" to 50.0),
),
slot(id = "slot_milk", name = "Mleko", amount = 200.0, unit = "ml"),
),
)
private fun slot(
id: String,
name: String,
amount: Double,
unit: String,
alternatives: List<Pair<String, Double>> = emptyList(),
): RecipeIngredientSlotUi =
RecipeIngredientSlotUi(
default =
RecipeIngredientOptionUi(
id = "$id:default",
name = name,
amount = amount,
unit = unit,
),
alternatives =
alternatives.map { (altName, altAmount) ->
RecipeIngredientOptionUi(
id = "$id:alt:$altName",
name = altName,
amount = altAmount,
unit = unit,
)
},
id = id,
)
private fun addable(
id: String,
name: String,
): AddableIngredientUi =
AddableIngredientUi(
ingredientId = id,
name = name,
defaultAmount = 10.0,
defaultUnit = "g",
)
}

View File

@@ -0,0 +1,92 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package dev.ulfrx.recipe.ui.keyboard
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import kotlinx.cinterop.DoubleVar
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.get
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.sizeOf
import kotlinx.cinterop.useContents
import platform.CoreGraphics.CGRect
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSNumber
import platform.Foundation.NSOperationQueue
import platform.Foundation.NSValue
import platform.UIKit.UIKeyboardAnimationDurationUserInfoKey
import platform.UIKit.UIKeyboardFrameEndUserInfoKey
import platform.UIKit.UIKeyboardWillChangeFrameNotification
import platform.UIKit.UIScreen
import kotlin.math.roundToInt
@Composable
internal actual fun rememberKeyboardTransitionState(): KeyboardTransitionState {
val currentInset = WindowInsets.ime.asPaddingValues().calculateBottomPadding()
var targetInset by remember { mutableStateOf(0.dp) }
var animationDurationMillis by remember { mutableStateOf(IosDefaultKeyboardAnimationDurationMillis) }
DisposableEffect(Unit) {
val observer =
NSNotificationCenter.defaultCenter.addObserverForName(
name = UIKeyboardWillChangeFrameNotification,
`object` = null,
queue = NSOperationQueue.mainQueue,
usingBlock = { notification ->
val userInfo = notification?.userInfo ?: return@addObserverForName
val frameValue = userInfo[UIKeyboardFrameEndUserInfoKey] as? NSValue
?: return@addObserverForName
val durationValue = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? NSNumber
val screenHeight =
UIScreen.mainScreen.bounds.useContents {
size.height
}
val keyboardTop =
memScoped {
// iOS app targets are arm64; CGRect is x, y, width, height
// as CGFloat/Double fields.
val keyboardFrame = allocArray<DoubleVar>(CGRectDoubleFieldCount)
frameValue.getValue(
value = keyboardFrame,
size = sizeOf<CGRect>().toULong(),
)
keyboardFrame[CGRectOriginYFieldIndex]
}
val targetHeight = (screenHeight - keyboardTop).coerceAtLeast(0.0)
targetInset = targetHeight.toFloat().dp
animationDurationMillis =
durationValue?.doubleValue
?.times(MillisPerSecond)
?.roundToInt()
?.takeIf { it > 0 }
?: IosDefaultKeyboardAnimationDurationMillis
},
)
onDispose {
NSNotificationCenter.defaultCenter.removeObserver(observer)
}
}
return KeyboardTransitionState(
currentInset = currentInset,
targetInset = targetInset,
animationDurationMillis = animationDurationMillis,
)
}
private const val IosDefaultKeyboardAnimationDurationMillis = 250
private const val MillisPerSecond = 1_000.0
private const val CGRectDoubleFieldCount = 4
private const val CGRectOriginYFieldIndex = 1