From d1916d3fe6a5ab11977db8818ee1d0500b595d72 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Thu, 4 Jun 2026 17:24:33 +0200 Subject: [PATCH] Add meal plan editor + smaller changes --- .planning/notes/nav-search-as-catalog.md | 39 -- .planning/seeds/home-tab-content.md | 36 -- .../pending/decide-add-recipe-placement.md | 29 -- .../KeyboardTransitionState.android.kt | 18 + .../composeResources/values/strings.xml | 24 + .../kotlin/dev/ulfrx/recipe/di/ShellModule.kt | 2 + .../ui/components/calendar/CalendarPill.kt | 127 ++++- .../calendar/CalendarWeekStrip.kt} | 17 +- .../calendar/CalendarWeekStripPager.kt | 128 +++++ .../components/calendar/RecipeCalendarPill.kt | 83 +++ .../ui/components/chips/MealSlotChip.kt | 82 +++ .../ui/components/glass/CircleGlassButton.kt | 11 +- .../ui/components/glass/GlassBackdrop.kt | 3 +- .../ui/components/glass/GlassSurface.kt | 6 +- .../ui/components/glass/GlassTextField.kt | 7 +- .../ui/components/recipe/IngredientAmount.kt | 65 +++ .../ui/components/recipe/IngredientCard.kt | 40 ++ .../ui/components/recipe/IngredientDivider.kt | 31 ++ .../ui/components/recipe/IngredientRow.kt | 97 ++-- .../recipe/ui/components/recipe/MealSlot.kt | 24 + .../recipe/RecipeServingsStepper.kt | 36 +- .../recipe/ui/components/section/Section.kt | 43 ++ .../ui/keyboard/KeyboardTransitionState.kt | 13 + .../mealplaneditor/AddIngredientPanel.kt | 492 ++++++++++++++++++ .../mealplaneditor/IngredientEditorList.kt | 147 ++++++ .../mealplaneditor/MealPlanEditorContent.kt | 245 +++++++++ .../mealplaneditor/MealPlanEditorState.kt | 23 + .../mealplaneditor/MealPlanEditorUi.kt | 39 ++ .../mealplaneditor/MealPlanEditorViewModel.kt | 120 +++++ .../mealplaneditor/MealSlotChipsRow.kt | 40 ++ .../mealplaneditor/SampleAddableCatalog.kt | 51 ++ .../ui/screens/planner/PlannerCalendarPill.kt | 49 +- .../ui/screens/planner/PlannerScreen.kt | 1 + .../ui/screens/planner/PlannerViewModel.kt | 11 +- .../screens/recipedetail/RecipeDetailHero.kt | 253 +++++---- .../screens/recipedetail/RecipeDetailSheet.kt | 438 +++++++++------- .../ui/screens/recipedetail/RecipeDetailUi.kt | 2 + .../recipedetail/SampleRecipeDetails.kt | 20 + .../recipe/ui/screens/search/SearchScreen.kt | 10 +- .../ulfrx/recipe/ui/screens/shell/AppShell.kt | 3 + .../ui/screens/shell/dock/DockLayers.kt | 1 - .../ui/screens/shell/dock/DockTabRow.kt | 2 +- .../shell/dock/FloatingSearchButton.kt | 1 + .../ui/screens/shell/search/SearchPill.kt | 2 +- .../dev/ulfrx/recipe/ui/theme/RecipeColors.kt | 13 +- .../dev/ulfrx/recipe/ui/theme/RecipeGlass.kt | 114 ++-- .../dev/ulfrx/recipe/ui/theme/RecipeTheme.kt | 9 +- .../MealPlanEditorViewModelTest.kt | 245 +++++++++ .../keyboard/KeyboardTransitionState.ios.kt | 92 ++++ 49 files changed, 2778 insertions(+), 606 deletions(-) delete mode 100644 .planning/notes/nav-search-as-catalog.md delete mode 100644 .planning/seeds/home-tab-content.md delete mode 100644 .planning/todos/pending/decide-add-recipe-placement.md create mode 100644 composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.android.kt rename composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/{screens/planner/PlannerWeekStrip.kt => components/calendar/CalendarWeekStrip.kt} (78%) create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarWeekStripPager.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/RecipeCalendarPill.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/chips/MealSlotChip.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientAmount.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientCard.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/IngredientDivider.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/recipe/MealSlot.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/section/Section.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/AddIngredientPanel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/IngredientEditorList.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorContent.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorState.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorUi.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealSlotChipsRow.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/SampleAddableCatalog.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/mealplaneditor/MealPlanEditorViewModelTest.kt create mode 100644 composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/keyboard/KeyboardTransitionState.ios.kt 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