diff --git a/.planning/notes/nav-search-as-catalog.md b/.planning/notes/nav-search-as-catalog.md
deleted file mode 100644
index 873c6b5..0000000
--- a/.planning/notes/nav-search-as-catalog.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-title: Nawigacja — search jako katalog przepisów + nowy tab Home
-date: 2026-05-16
-context: Eksploracja zmiany struktury nawigacji przed Phase 2.1 (app shell/navigation/search) i Phase 5 (Recipe catalog)
----
-
-# Nawigacja — search jako katalog przepisów + nowy tab Home
-
-## Decyzje kierunkowe
-
-1. **Usuwamy tab Przepisy z docka.** Search button (siostra docka, zgodnie z `project_dock_layout.md`) staje się wejściem do bogatego ekranu-katalogu z kategoriami, browse i filtrem po składnikach. Wpisywanie tekstu w pole to drugorzędna ścieżka — główna interakcja to *przeglądanie*, nie *known-item search*.
-
-2. **Dodajemy tab Home przed Planerem.** Charakter: landing z podsumowaniami i propozycjami — ma *coś podpowiadać* użytkownikowi, a nie być pasywnym ekranem powitalnym. Konkretny scope widgetów odłożony (patrz seed `home-tab-content.md`).
-
-## Driver
-
-Rezerwa slotów w docku na przyszłe funkcje — m.in. "co mam w lodówce → przepis", dodawanie przepisów, potencjalnie inne discovery flows. Bezpośrednia inspiracja: Apple Music (search jako siostra/przycisk obok zakładek, otwierający kategorie zanim użytkownik cokolwiek wpisze).
-
-## Napięcie do rozwiązania w Phase 2.1
-
-Obecny model search (z memory `project_dock_layout.md`) to **overlay w 3 stanach** (closed / open-unfocused / open-focused) z zablokowanym backiem. Nowy model wymaga **pełnej destynacji** z back-stackiem: browse kategorii → szczegóły przepisu → back do listy → filtr → itd.
-
-Trzy ścieżki do rozważenia w designie:
-- **A.** Overlay rozrasta się płynnie w pełen ekran (animacja przejścia stan otwarty-niezaogniskowany → destynacja); back-stack aktywuje się dopiero po pierwszej akcji navigacyjnej.
-- **B.** Search button przestaje być overlayem; od razu nawiguje do dedykowanej destynacji (ekran katalogu z search bar na górze). Prościej, ale tracimy "lekkość" overlaya na innych ekranach.
-- **C.** Hybryda: overlay pozostaje dla quick-search z każdego ekranu (wpisz frazę → wyniki), ale tap w "browse categories" wewnątrz overlaya nawiguje do pełnej destynacji.
-
-Decyzja do podjęcia w `/gsd-discuss-phase 2.1` lub w sketch passie.
-
-## Sprawy zaparkowane
-
-- Umiejscowienie akcji "+Dodaj przepis" (Home / toolbar app shell / ekran katalogu / ustawienia). Niska częstotliwość użycia — może być dwa-tapy głębiej. Patrz todo `decide-add-recipe-placement.md`.
-- Konkretny scope widgetów Home. Patrz seed `home-tab-content.md`.
-
-## Wpływ na roadmapę
-
-- **Phase 2.1 (app shell/navigation/search)** — zmienia się definicja search button i layout docka (4-tab → 5-tab: Home, Planer, Spiżarnia, Zakupy + search button jako sibling, bez taba Przepisy).
-- **Phase 5 (Recipe catalog)** — ekran katalogu nie ma własnego taba; wejście wyłącznie przez search button. Pozostała funkcjonalność (lista, kategorie, szczegóły, filtry) bez zmian.
-- **Phase 10 (UI chrome polish)** — Home prawdopodobnie domyka się tutaj wizualnie.
diff --git a/.planning/seeds/home-tab-content.md b/.planning/seeds/home-tab-content.md
deleted file mode 100644
index 7c6a4d6..0000000
--- a/.planning/seeds/home-tab-content.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-title: Scope widgetów tab Home
-trigger_condition: Przed rozpoczęciem Phase 10 (UI chrome polish) lub gdy Phase 2.1 (app shell/navigation/search) wchodzi w fazę projektowania ekranów
-planted_date: 2026-05-16
----
-
-# Scope widgetów tab Home
-
-## Kontekst
-
-Decyzja kierunkowa (patrz `notes/nav-search-as-catalog.md`): dodajemy tab Home przed Planerem. Charakter: landing z podpowiedziami i podsumowaniami — *coś musi mówić użytkownikowi*, nie być pustym powitaniem.
-
-## Otwarte pytanie
-
-Co konkretnie pokazujemy na Home, żeby:
-- nie dublować funkcji Planera/Spiżarni/Zakupów,
-- być realną wartością przy pierwszym otwarciu appki rano,
-- nie spuchnąć do nieczytelnego dashboardu z 8 sekcjami.
-
-## Kandydaci do rozważenia
-
-- **"Co jemy dziś / jutro"** — wycinek planera w formie karty; tap → Planer otwarty na danym dniu.
-- **"Czego brakuje do najbliższych posiłków"** — shortcut do brakujących składników; tap → Zakupy / Spiżarnia.
-- **"Propozycje przepisów"** — sugestie oparte na: ostatnio dodane, sezonowe, "na podstawie tego co masz w lodówce" (przyszła funkcja).
-- **"Szybkie akcje"** — m.in. potencjalne miejsce na "+Dodaj przepis".
-- **Powitanie / pasywny landing** — odrzucone we wstępnej dyskusji (Home ma podpowiadać).
-
-## Pytania pomocnicze do rozstrzygnięcia później
-
-- Czy Home jest *statyczny* (zawsze te same sekcje), czy *adaptacyjny* (sekcje pojawiają się zależnie od stanu — np. "brakuje produktów" tylko gdy faktycznie brakuje)?
-- Czy Home wystarczy na MVP w minimalnej formie (1-2 sekcje), czy czekamy z nim do momentu, gdy mamy więcej funkcji do podsumowania?
-- Czy Home jest też miejscem na onboarding (przy pierwszym uruchomieniu)?
-
-## Rekomendowana ścieżka
-
-`/gsd-sketch` na początku Phase 2.1 lub Phase 10 — Home to wyraźny przypadek "what should this look like / feel" z wieloma stanami do wyklikania w HTML.
diff --git a/.planning/todos/pending/decide-add-recipe-placement.md b/.planning/todos/pending/decide-add-recipe-placement.md
deleted file mode 100644
index 003fc33..0000000
--- a/.planning/todos/pending/decide-add-recipe-placement.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-title: Zdecyduj gdzie ląduje akcja "+Dodaj przepis"
-date: 2026-05-16
-priority: medium
-blocks: Phase 5 (Recipe catalog)
----
-
-# Zdecyduj gdzie ląduje akcja "+Dodaj przepis"
-
-## Kontekst
-
-Po decyzji o usunięciu taba Przepisy (patrz `notes/nav-search-as-catalog.md`) nie ma już oczywistego miejsca na akcję dodawania przepisu. Dodawanie ma być raczej *dodatkiem*, nie codzienną interakcją — może być dwa-tapy głębiej.
-
-## Kandydaci
-
-- **Home** — naturalne miejsce na "szybkie akcje"; wymaga że Home już istnieje i ma miejsce na taki shortcut.
-- **Toolbar app shell** — globalny "+" obok search button; zawsze pod ręką, ale konkuruje o przestrzeń z search i ewentualnymi przyszłymi przyciskami.
-- **Ekran katalogu (otwierany przez search)** — semantycznie sensowne (jest o przepisach), ale dziwne że użytkownik musi przejść przez search żeby coś dodać.
-- **Ustawienia** — najbardziej ukryte; OK jeśli dodawanie jest skrajnie rzadkie.
-
-## Kryteria decyzyjne
-
-- Częstotliwość użycia (im rzadsze, tym głębiej można schować).
-- Czy "+Dodaj przepis" to akcja związana z *katalogiem* (sensowne w toolbarze katalogu) czy *zarządzaniem treścią* (sensowne w ustawieniach / Home jako command center).
-- Spójność z innymi akcjami tworzenia, które mogą dojść w v2 (np. dodawanie produktu do spiżarni — gdzie ona ląduje?).
-
-## Termin
-
-Rozstrzygnąć przed `/gsd-plan-phase 5` (Recipe catalog). Można też w `/gsd-discuss-phase 2.1` jeśli decyzja wpływa na layout app shell.
diff --git a/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.android.kt b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.android.kt
new file mode 100644
index 0000000..26524e4
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.android.kt
@@ -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
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 9d85a64..239b407 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -76,4 +76,28 @@
%1$d braków
%1$d do kupienia
+
+
+ Śniadanie
+ Lunch
+ Obiad
+ Kolacja
+ Przekąska
+
+
+ Zaplanuj posiłek
+ Wróć do szczegółów przepisu
+ Dodaj
+ Dodaj posiłek do planu
+ Pora posiłku
+ Porcje
+ Składniki
+ Dodaj składnik
+ Szukaj składnika…
+ Anuluj
+ Brak wyników
+ %1$d usuniętych
+ Przywróć
+ Usuń składnik
+ Dodany składnik
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
index d216b81..1eb023b 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
@@ -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()
viewModel()
viewModel()
+ viewModel()
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt
index b89c621..61a70c0 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerWeekStrip.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarWeekStrip.kt
similarity index 78%
rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerWeekStrip.kt
rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarWeekStrip.kt
index 983912f..70f86ed 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerWeekStrip.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarWeekStrip.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarWeekStripPager.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarWeekStripPager.kt
new file mode 100644
index 0000000..40a22b0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarWeekStripPager.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/RecipeCalendarPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/RecipeCalendarPill.kt
new file mode 100644
index 0000000..d91fd50
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/RecipeCalendarPill.kt
@@ -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 = 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/chips/MealSlotChip.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/chips/MealSlotChip.kt
new file mode 100644
index 0000000..e0bcba4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/chips/MealSlotChip.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt
index 33844ee..a50d4e5 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt
index a9ad356..2d735f2 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt
@@ -36,7 +36,8 @@ fun GlassBackdropSource(
content: @Composable BoxScope.() -> Unit,
) {
Box(
- modifier = modifier.liquefiable(state.liquidState),
+ modifier = modifier
+ .liquefiable(state.liquidState),
content = content,
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt
index 560a711..90e4fad 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt
@@ -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,
)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt
index eee0375..df34af6 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientAmount.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientAmount.kt
new file mode 100644
index 0000000..36b3bb6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientAmount.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientCard.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientCard.kt
new file mode 100644
index 0000000..e8a5a88
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientCard.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientDivider.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientDivider.kt
new file mode 100644
index 0000000..c491c96
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientDivider.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt
index cd27bd8..718667c 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientRow.kt
@@ -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
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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/MealSlot.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/MealSlot.kt
new file mode 100644
index 0000000..dc9b9de
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/MealSlot.kt
@@ -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),
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeServingsStepper.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeServingsStepper.kt
index 11b7825..2a1602e 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeServingsStepper.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/RecipeServingsStepper.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/section/Section.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/section/Section.kt
new file mode 100644
index 0000000..0e5058d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/section/Section.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.kt
new file mode 100644
index 0000000..3d3bf52
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/AddIngredientPanel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/AddIngredientPanel.kt
new file mode 100644
index 0000000..0df7458
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/AddIngredientPanel.kt
@@ -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,
+ usedIngredientIds: Set,
+ 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,
+ usedIngredientIds: Set,
+ 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,
+ 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,
+ usedIngredientIds: Set,
+ query: String,
+ maxResults: Int,
+): List {
+ 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/IngredientEditorList.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/IngredientEditorList.kt
new file mode 100644
index 0000000..4ff14cc
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/IngredientEditorList.kt
@@ -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,
+ addedIngredients: List,
+ excludedIngredientIds: Set,
+ substitutions: Map,
+ 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt
new file mode 100644
index 0000000..dd6a192
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt
@@ -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,
+ 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt
new file mode 100644
index 0000000..975166e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt
@@ -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 = emptyMap(),
+ val excludedIngredients: Set = emptySet(),
+ val addedIngredients: List = emptyList(),
+ ) : MealPlanEditorState
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt
new file mode 100644
index 0000000..b088791
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt
@@ -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,
+ val excludedIngredients: Set,
+ val addedIngredients: List,
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt
new file mode 100644
index 0000000..1373d13
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt
@@ -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.Hidden)
+ val state: StateFlow = _state.asStateFlow()
+
+ fun open(
+ recipe: RecipeDetailUi,
+ initialSubstitutions: Map = 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.filterValid(recipe: RecipeDetailUi): Map =
+ 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,
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealSlotChipsRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealSlotChipsRow.kt
new file mode 100644
index 0000000..e08920c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealSlotChipsRow.kt
@@ -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,
+ allowedSlots: List,
+ 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) },
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/SampleAddableCatalog.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/SampleAddableCatalog.kt
new file mode 100644
index 0000000..a76ddc9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/SampleAddableCatalog.kt
@@ -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 =
+ 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,
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt
index 6e52ada..c32e798 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt
@@ -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,
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt
index c05f72a..28d191d 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt
@@ -38,6 +38,7 @@ fun PlannerScreen(viewModel: PlannerViewModel) {
expanded = state.isCalendarOpen,
onExpandedChange = viewModel::setCalendarOpen,
onSelectDate = viewModel::selectDate,
+ onShiftSelection = viewModel::shiftSelection,
)
},
) {
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt
index 0ddf3aa..397c508 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt
@@ -18,7 +18,16 @@ class PlannerViewModel : ViewModel() {
val state: StateFlow = _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) {
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailHero.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailHero.kt
index be31998..a88bb26 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailHero.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailHero.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
index c353b87..a121b49 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
@@ -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,
@@ -231,17 +359,8 @@ private fun IngredientsSection(
substitutions: Map,
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) {
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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt
index 3884720..cdd5831 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailUi.kt
@@ -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,
val steps: List,
+ val allowedSlots: List,
)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt
index c7e286c..b4b68b0 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/SampleRecipeDetails.kt
@@ -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 =
listOf(
RecipeDetailUi(
@@ -11,6 +16,7 @@ internal val sampleRecipeDetails: Map =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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 =
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."),
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
index 9a6c6f3..1705e4d 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
@@ -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,
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
index 964ce88..35c2325 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
@@ -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 {
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt
index 7ee0c5b..1d35701 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt
@@ -96,6 +96,5 @@ internal fun DockPressOverlayLayer(
}.alpha(overlayAlpha),
cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress,
- tint = RecipeTheme.colors.surfaceGlassOverlay,
) {}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt
index 8773de3..dfba2a6 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt
@@ -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 {
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/FloatingSearchButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/FloatingSearchButton.kt
index eb91c62..8f7e5d6 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/FloatingSearchButton.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/FloatingSearchButton.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchPill.kt
index c35c7f8..5eb5466 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchPill.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchPill.kt
@@ -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),
)
},
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt
index c996130..b59b8db 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt
@@ -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),
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
index 85bf082..820cbc9 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
@@ -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,
- )
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
index ce1bbfc..748c839 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
@@ -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 =
public val LocalRecipeShapes: ProvidableCompositionLocal =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
+public val LocalRecipeGlass: ProvidableCompositionLocal =
+ 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
}
diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModelTest.kt
new file mode 100644
index 0000000..9912b52
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModelTest.kt
@@ -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 = 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> = 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",
+ )
+}
diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.ios.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.ios.kt
new file mode 100644
index 0000000..e238f90
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.ios.kt
@@ -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(CGRectDoubleFieldCount)
+ frameValue.getValue(
+ value = keyboardFrame,
+ size = sizeOf().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