Compare commits

..

5 Commits

66 changed files with 3247 additions and 967 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -93,6 +93,7 @@ kotlin {
implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings)
implementation(libs.lokksmith.compose) implementation(libs.lokksmith.compose)
implementation(libs.navigation3.ui) implementation(libs.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodelNavigation3)
implementation(libs.compose.unstyled) implementation(libs.compose.unstyled)
implementation(libs.compose.icons.lucide) implementation(libs.compose.icons.lucide)
implementation(libs.liquid) implementation(libs.liquid)

View File

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

View File

@@ -42,14 +42,15 @@
<string name="ingredient_substitute_a11y">Zamień składnik</string> <string name="ingredient_substitute_a11y">Zamień składnik</string>
<!-- Phase 2.1 — Recipe detail sheet (UI-only view; planner wiring lands in later phases) --> <!-- Phase 2.1 — Recipe detail sheet (UI-only view; planner wiring lands in later phases) -->
<string name="recipe_detail_plan_button">Zaplanuj</string>
<string name="recipe_detail_servings_label">Porcje</string> <string name="recipe_detail_servings_label">Porcje</string>
<string name="recipe_detail_section_ingredients">Składniki</string> <string name="recipe_detail_section_ingredients">Składniki</string>
<string name="recipe_detail_section_steps">Kroki</string> <string name="recipe_detail_section_steps">Kroki</string>
<string name="recipe_detail_step_number_format">%1$d.</string> <string name="recipe_detail_step_number_format">%1$d.</string>
<string name="recipe_detail_servings_decrement_a11y">Zmniejsz liczbę porcji</string> <string name="recipe_detail_servings_decrement_a11y">Zmniejsz liczbę porcji</string>
<string name="recipe_detail_servings_increment_a11y">Zwiększ liczbę porcji</string> <string name="recipe_detail_servings_increment_a11y">Zwiększ liczbę porcji</string>
<string name="recipe_detail_handle_a11y">Przeciągnij, aby zamknąć szczegóły przepisu</string> <string name="sheet_drag_handle_a11y">Przeciągnij w dół, aby zamknąć</string>
<string name="recipe_detail_not_found">Nie znaleziono przepisu</string>
<string name="meal_plan_editor_not_found">Nie udało się otworzyć edytora</string>
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) --> <!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string> <string name="search_open_a11y">Otwórz wyszukiwanie</string>
@@ -76,4 +77,29 @@
<!-- Dummy metryki pilla (UI-first; realne dane w fazach 8/9) --> <!-- Dummy metryki pilla (UI-first; realne dane w fazach 8/9) -->
<string name="pantry_shortfall_count">%1$d braków</string> <string name="pantry_shortfall_count">%1$d braków</string>
<string name="shopping_buy_count">%1$d do kupienia</string> <string name="shopping_buy_count">%1$d do kupienia</string>
<!-- Pory posiłku — wspólne dla detalu i edytora planu (Phase 6 polishes copy + plurals) -->
<string name="meal_slot_breakfast">Śniadanie</string>
<string name="meal_slot_lunch">Lunch</string>
<string name="meal_slot_dinner">Obiad</string>
<string name="meal_slot_supper">Kolacja</string>
<string name="meal_slot_snack">Przekąska</string>
<!-- Phase 6 — Meal plan editor (UI-first; planStore wiring lands in later phases) -->
<string name="meal_plan_editor_title">Zaplanuj posiłek</string>
<string name="meal_plan_editor_title_a11y">Dodaj posiłek do planu</string>
<string name="meal_plan_editor_back_a11y">Wróć do szczegółów przepisu</string>
<string name="meal_plan_editor_confirm">Dodaj</string>
<string name="meal_plan_editor_confirm_a11y">Dodaj posiłek do planu</string>
<string name="meal_plan_editor_section_slot">Pora posiłku</string>
<string name="meal_plan_editor_section_servings">Porcje</string>
<string name="meal_plan_editor_section_ingredients">Składniki</string>
<string name="meal_plan_editor_add_ingredient">Dodaj składnik</string>
<string name="meal_plan_editor_add_ingredient_search_placeholder">Szukaj składnika…</string>
<string name="meal_plan_editor_add_ingredient_cancel">Anuluj</string>
<string name="meal_plan_editor_add_ingredient_empty">Brak wyników</string>
<string name="meal_plan_editor_removed_format">%1$d usuniętych</string>
<string name="meal_plan_editor_removed_restore">Przywróć</string>
<string name="meal_plan_editor_remove_ingredient_a11y">Usuń składnik</string>
<string name="meal_plan_editor_added_marker_a11y">Dodany składnik</string>
</resources> </resources>

View File

@@ -1,12 +1,16 @@
package dev.ulfrx.recipe.di package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel 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.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.sampleRecipe
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel import org.koin.plugin.module.dsl.viewModel
@@ -18,5 +22,16 @@ val shellModule =
viewModel<ShoppingViewModel>() viewModel<ShoppingViewModel>()
viewModel<ShellSearchViewModel>() viewModel<ShellSearchViewModel>()
viewModel<RecipeCatalogViewModel>() viewModel<RecipeCatalogViewModel>()
viewModel<RecipeDetailViewModel>()
viewModel { (recipeId: String) ->
RecipeDetailViewModel(recipeId = recipeId)
}
viewModel { (source: MealPlanEditorSource) ->
MealPlanEditorViewModel(
source = source,
recipeProvider = ::sampleRecipe,
// Phase 6 swaps this for the real PlannedMealsRepository lookup.
plannedMealProvider = { null },
)
}
} }

View File

@@ -0,0 +1,16 @@
package dev.ulfrx.recipe.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed interface MealPlanEditorSource {
@Serializable
data class NewFromRecipe(
val recipeId: String,
val initialServings: Int = 1,
val initialSubstitutions: Map<String, String> = emptyMap(),
) : MealPlanEditorSource
@Serializable
data class EditExistingPlan(val plannedMealId: String) : MealPlanEditorSource
}

View File

@@ -4,16 +4,9 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the * Each leaf is `@Serializable` because Nav 3 persists the back stack via
* back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration). * kotlinx-serialization (process-death restore). Recipes have no tab — they
* * land in [RecipeDetail] via the shell-wide search overlay.
* Screens are grouped by tab so future detail destinations slot in without
* polluting the top-level namespace — e.g. `Screen.Pantry.Detail(id)`. The
* grouping is purely a code-organisation convenience; Nav 3 treats each leaf as
* an independent NavKey regardless of nesting.
*
* The Recipes catalog has no own tab — it is reached via the shell-wide search
* destination (see `ShellSearchViewModel`).
*/ */
sealed interface Screen : NavKey { sealed interface Screen : NavKey {
sealed interface Home : Screen { sealed interface Home : Screen {
@@ -35,4 +28,12 @@ sealed interface Screen : NavKey {
@Serializable @Serializable
data object Home : Shopping data object Home : Shopping
} }
@Serializable
data class RecipeDetail(val recipeId: String) : Screen
sealed interface MealPlanEditor : Screen {
@Serializable
data class Open(val source: MealPlanEditorSource) : MealPlanEditor
}
} }

View File

@@ -0,0 +1,75 @@
package dev.ulfrx.recipe.ui.components.button
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.RecipeTheme
@Composable
fun CircleButton(
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
tint: Color = RecipeTheme.colors.surface,
iconTint: Color = RecipeTheme.colors.content,
iconSize: Dp = 24.dp,
borderTint: Color = RecipeTheme.colors.borderCard,
borderWidth: Dp = 1.dp,
onClick: () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.15f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "CircleGlassButton scale",
)
UnstyledButton(
onClick = onClick,
contentPadding = PaddingValues(0.dp),
interactionSource = interactionSource,
indication = null,
modifier = modifier
.scale(scale)
.size(size),
backgroundColor = tint,
borderColor = borderTint,
borderWidth = borderWidth,
shape = CircleShape,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(iconSize),
)
}
}
}

View File

@@ -3,16 +3,20 @@ package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
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.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -27,6 +31,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow 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.unit.dp
import androidx.compose.ui.util.lerp import androidx.compose.ui.util.lerp
import dev.ulfrx.recipe.ui.components.glass.GlassSurface 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.RecipeTheme
import dev.ulfrx.recipe.ui.theme.lerp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate 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 @Composable
fun CalendarPill( fun CalendarPill(
expanded: Boolean, expanded: Boolean,
@@ -56,6 +79,9 @@ fun CalendarPill(
dayState: (LocalDate) -> DayState = { DayState() }, dayState: (LocalDate) -> DayState = { DayState() },
pillHeight: Dp = 48.dp, pillHeight: Dp = 48.dp,
locale: CalendarLocale = CalendarLocale.PL, locale: CalendarLocale = CalendarLocale.PL,
expandDirection: CalendarPillExpandDirection = CalendarPillExpandDirection.Up,
tint: Color = RecipeTheme.colors.surfaceGlass,
glass: Boolean = true,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val expansion = remember { PillExpansion(initial = if (expanded) 1f else 0f) } val expansion = remember { PillExpansion(initial = if (expanded) 1f else 0f) }
@@ -66,34 +92,44 @@ fun CalendarPill(
val progress = expansion.progress val progress = expansion.progress
val cornerRadius = pillHeight / 2 * (1f - progress) + EXPANDED_CORNER_RADIUS * 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 pillInset = RecipeTheme.spacing.lg + RecipeTheme.spacing.xs
val pillHeightPx = with(LocalDensity.current) { pillHeight.toPx() } val pillHeightPx = with(LocalDensity.current) { pillHeight.toPx() }
val dragState = val dragState =
rememberDraggableState { delta -> 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 =
modifier.draggable( modifier.draggable(
state = dragState, state = dragState,
orientation = Orientation.Vertical, orientation = Orientation.Vertical,
onDragStarted = { expansion.cancelSettle() }, onDragStarted = { expansion.cancelSettle() },
onDragStopped = { velocity -> onDragStopped = { velocity ->
val openTarget = releaseTarget(expansion.progress, velocity) val openTarget = releaseTarget(expansion.progress, velocity, expandDirection)
val range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f) 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) if (openTarget != expanded) onExpandedChange(openTarget)
}, },
), ),
cornerRadius = cornerRadius,
glassStyle = glassStyle,
) { ) {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
CompositionLocalProvider(LocalCalendarInteractive provides expanded) { CompositionLocalProvider(LocalCalendarInteractive provides expanded) {
Box( Box(
modifier = Modifier.fillMaxWidth().expandingHeight(progress, pillHeight, expansion).alpha(progress), modifier =
Modifier
.fillMaxWidth()
.expandingHeight(progress, pillHeight, expansion, expandDirection)
.alpha(progress),
) { ) {
SwipeableCalendar( SwipeableCalendar(
selectedDate = selectedDate, selectedDate = selectedDate,
@@ -112,11 +148,16 @@ fun CalendarPill(
val rowAlpha = (1f - progress / PILL_CONTENT_FADE_END).coerceIn(0f, 1f) val rowAlpha = (1f - progress / PILL_CONTENT_FADE_END).coerceIn(0f, 1f)
if (rowAlpha > 0f) { if (rowAlpha > 0f) {
val pillRowAlignment =
when (expandDirection) {
CalendarPillExpandDirection.Up -> Alignment.BottomCenter
CalendarPillExpandDirection.Down -> Alignment.TopCenter
}
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(pillRowAlignment)
.alpha(rowAlpha), .alpha(rowAlpha),
) { ) {
PillRow( 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 @Composable
private fun PillRow( private fun PillRow(
label: String, label: String,
@@ -166,13 +244,16 @@ private fun PillRow(
/** /**
* Measures the calendar at its full intrinsic height, reports it to [expansion] * 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 * so drag knows the range, then lays out at the lerped height. The placement
* bottom edge so the calendar slides down from above the pill row. * 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( private fun Modifier.expandingHeight(
progress: Float, progress: Float,
pillHeight: Dp, pillHeight: Dp,
expansion: PillExpansion, expansion: PillExpansion,
direction: CalendarPillExpandDirection,
): Modifier = ): Modifier =
this.layout { measurable, constraints -> this.layout { measurable, constraints ->
val placeable = val placeable =
@@ -181,7 +262,12 @@ private fun Modifier.expandingHeight(
val pillHeightPx = pillHeight.roundToPx() val pillHeightPx = pillHeight.roundToPx()
val height = lerp(pillHeightPx, placeable.height, progress).coerceIn(pillHeightPx, placeable.height) val height = lerp(pillHeightPx, placeable.height, progress).coerceIn(pillHeightPx, placeable.height)
layout(placeable.width, 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)
} }
} }
@@ -191,7 +277,9 @@ private fun Modifier.expandingHeight(
* that match an in-flight settle become no-ops — no flag, no race. * that match an in-flight settle become no-ops — no flag, no race.
*/ */
@Stable @Stable
private class PillExpansion(initial: Float) { private class PillExpansion(
initial: Float,
) {
var progress by mutableFloatStateOf(initial) var progress by mutableFloatStateOf(initial)
private set private set
var fullHeightPx by mutableIntStateOf(0) var fullHeightPx by mutableIntStateOf(0)
@@ -200,19 +288,28 @@ private class PillExpansion(initial: Float) {
private var target: Float = initial private var target: Float = initial
private var settleJob: Job? = null private var settleJob: Job? = null
fun dragBy(delta: Float, range: Float) { fun dragBy(
delta: Float,
range: Float,
direction: CalendarPillExpandDirection,
) {
settleJob?.cancel() settleJob?.cancel()
progress = (progress - delta / range).coerceIn(0f, 1f) progress = (progress + direction.openingSign * delta / range).coerceIn(0f, 1f)
target = progress target = progress
} }
fun animateTo(scope: CoroutineScope, target: Float, initialVelocity: Float = 0f) { fun animateTo(
scope: CoroutineScope,
target: Float,
initialVelocity: Float = 0f,
) {
if (this.target == target && settleJob?.isActive == true) return if (this.target == target && settleJob?.isActive == true) return
this.target = target this.target = target
settleJob?.cancel() settleJob?.cancel()
settleJob = settleJob =
scope.launch { scope.launch {
Animatable(progress).also { it.updateBounds(0f, 1f) } Animatable(progress)
.also { it.updateBounds(0f, 1f) }
.animateTo( .animateTo(
targetValue = target, targetValue = target,
animationSpec = animationSpec =
@@ -237,13 +334,17 @@ private class PillExpansion(initial: Float) {
private fun releaseTarget( private fun releaseTarget(
progress: Float, progress: Float,
velocity: Float, velocity: Float,
): Boolean = direction: CalendarPillExpandDirection,
when { ): Boolean {
velocity <= -FLING_VELOCITY -> true val openingVelocity = direction.openingSign * velocity
velocity >= FLING_VELOCITY -> false return when {
openingVelocity >= FLING_VELOCITY -> true
openingVelocity <= -FLING_VELOCITY -> false
else -> progress >= 0.5f else -> progress >= 0.5f
} }
}
private const val FLING_VELOCITY = 60f private const val FLING_VELOCITY = 60f
private const val PILL_CONTENT_FADE_END = 0.35f private const val PILL_CONTENT_FADE_END = 0.35f
private val EXPANDED_CORNER_RADIUS = 28.dp private val EXPANDED_CORNER_RADIUS = 28.dp
private val FlatBorderWidth = 1.dp

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.screens.planner package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -9,14 +9,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp 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 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 @Composable
fun PlannerWeekStrip( fun CalendarWeekStrip(
selectedDate: LocalDate, selectedDate: LocalDate,
today: LocalDate, today: LocalDate,
onSelectDate: (LocalDate) -> Unit, onSelectDate: (LocalDate) -> Unit,
@@ -28,7 +29,7 @@ fun PlannerWeekStrip(
val days = weekStripDays(selectedDate) val days = weekStripDays(selectedDate)
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(DayCellGap),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
days.forEachIndexed { index, day -> days.forEachIndexed { index, day ->
@@ -46,3 +47,5 @@ fun PlannerWeekStrip(
} }
} }
} }
private val DayCellGap = 4.dp

View File

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

View File

@@ -43,7 +43,6 @@ class HorizonCalendarHolder(
companion object { companion object {
private const val DEFAULT_HORIZON_DAYS = 7 private const val DEFAULT_HORIZON_DAYS = 7
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate = fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate = today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,8 @@ class OverlayDismisser {
} }
} }
val LocalOverlayDismisser = staticCompositionLocalOf<OverlayDismisser> { val LocalOverlayDismisser =
staticCompositionLocalOf<OverlayDismisser> {
error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.") error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.")
} }

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package dev.ulfrx.recipe.ui.components.recipe package dev.ulfrx.recipe.ui.components.recipe
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -24,19 +24,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Check import com.composables.icons.lucide.Check
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Shuffle import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.X
import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.ingredient_substitute_a11y 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( data class RecipeIngredientOptionUi(
val id: String, val id: String,
@@ -51,15 +55,21 @@ data class RecipeIngredientSlotUi(
val id: String = default.id, 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 @Composable
fun IngredientRow( fun IngredientRow(
slot: RecipeIngredientSlotUi, slot: RecipeIngredientSlotUi,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selectedOptionId: String = slot.default.id, selectedOptionId: String = slot.default.id,
onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null, onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null,
addedMarker: Boolean = false,
onRemove: (() -> Unit)? = null,
) { ) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val options = slot.options val options = slot.options
val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default
val swappable = slot.alternatives.isNotEmpty() && onSelect != null val swappable = slot.alternatives.isNotEmpty() && onSelect != null
@@ -69,33 +79,37 @@ fun IngredientRow(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(CornerRadius))
.background(colors.surface)
.animateContentSize(), .animateContentSize(),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = MinRowHeight)
.padding(horizontal = PaddingHorizontal, vertical = PaddingVertical), .padding(horizontal = PaddingHorizontal, vertical = PaddingVertical),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) { ) {
BasicText( NameLine(
text = selected.name, name = selected.name,
style = addedMarker = addedMarker,
typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Medium,
fontSize = NameTextSize,
lineHeight = LineHeight,
),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (swappable) { 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) { if (swappable && expanded) {
@@ -122,50 +136,53 @@ fun IngredientRow(
} }
@Composable @Composable
private fun AmountLabel( private fun NameLine(
amount: Double, name: String,
unit: String, addedMarker: Boolean,
modifier: Modifier = Modifier,
) { ) {
val colors = RecipeTheme.colors val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
Row( Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) { ) {
BasicText( BasicText(
text = formatAmount(amount), text = name,
style = style =
typography.body.copy( RecipeTheme.typography.body.copy(
color = colors.content, color = colors.content,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
fontSize = NameTextSize, fontSize = NameTextSize,
lineHeight = LineHeight, lineHeight = LineHeight,
), ),
) )
BasicText( if (addedMarker) {
text = unit, UnstyledIcon(
style = imageVector = Lucide.Plus,
typography.body.copy( contentDescription = stringResource(Res.string.meal_plan_editor_added_marker_a11y),
color = colors.contentMuted, tint = colors.contentMuted,
fontSize = UnitTextSize, modifier = Modifier.size(AddedMarkerSize),
lineHeight = LineHeight,
),
) )
} }
} }
}
@Composable @Composable
private fun SwapToggle(onClick: () -> Unit) { private fun IconBadgeButton(
val colors = RecipeTheme.colors icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
UnstyledButton( UnstyledButton(
onClick = onClick, onClick = onClick,
modifier = Modifier.size(ToggleSize), modifier = Modifier.size(ToggleSize),
) { ) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
UnstyledIcon( UnstyledIcon(
imageVector = Lucide.Shuffle, imageVector = icon,
contentDescription = stringResource(Res.string.ingredient_substitute_a11y), contentDescription = contentDescription,
tint = colors.contentMuted, tint = RecipeTheme.colors.contentMuted,
modifier = Modifier.size(ToggleIconSize), modifier = Modifier.size(ToggleIconSize),
) )
} }
@@ -206,7 +223,7 @@ private fun AlternativeOption(
) )
Spacer(Modifier.height(OptionMetaGap)) Spacer(Modifier.height(OptionMetaGap))
BasicText( BasicText(
text = formatAmount(option.amount) + " " + option.unit, text = formatIngredientAmount(option.amount) + " " + option.unit,
style = style =
typography.body.copy( typography.body.copy(
color = colors.contentMuted, color = colors.contentMuted,
@@ -246,13 +263,6 @@ private fun SelectionMark(selected: Boolean) {
} }
} }
private fun formatAmount(value: Double): String {
val scaled = round(value * 10.0).toLong()
val whole = scaled / 10
val frac = (scaled % 10).toInt()
return if (frac == 0) whole.toString() else "$whole,$frac"
}
internal val RecipeIngredientSlotUi.options: List<RecipeIngredientOptionUi> internal val RecipeIngredientSlotUi.options: List<RecipeIngredientOptionUi>
get() = listOf(default) + alternatives get() = listOf(default) + alternatives
@@ -263,14 +273,14 @@ internal fun RecipeIngredientSlotUi.scaledBy(servings: Int) =
id = id, id = id,
) )
private val CornerRadius = 12.dp private val MinRowHeight = 48.dp
private val PaddingHorizontal = 12.dp private val PaddingHorizontal = 12.dp
private val PaddingVertical = 16.dp private val PaddingVertical = 12.dp
private val NameTextSize = 13.sp private val NameTextSize = 12.sp
private val UnitTextSize = 12.sp private val LineHeight = 16.sp
private val LineHeight = 18.sp private val ToggleSize = 24.dp
private val ToggleSize = 28.dp private val ToggleIconSize = 12.dp
private val ToggleIconSize = 14.dp private val AddedMarkerSize = 10.dp
private val OptionCornerRadius = 10.dp private val OptionCornerRadius = 10.dp
private val OptionPadding = 12.dp private val OptionPadding = 12.dp
private val OptionMetaGap = 2.dp private val OptionMetaGap = 2.dp

View File

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

View File

@@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -45,6 +46,7 @@ fun NutritionSummary(
nutrition: RecipeNutritionUi, nutrition: RecipeNutritionUi,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = RecipeTheme.colors
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
@@ -53,21 +55,25 @@ fun NutritionSummary(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = nutrition.kcal.toString(), value = nutrition.kcal.toString(),
label = stringResource(Res.string.nutrition_macro_kcal), label = stringResource(Res.string.nutrition_macro_kcal),
valueColor = colors.content,
) )
MacroCard( MacroCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.protein), value = stringResource(Res.string.nutrition_grams_format, nutrition.protein),
label = stringResource(Res.string.nutrition_macro_protein), label = stringResource(Res.string.nutrition_macro_protein),
valueColor = colors.macroProtein,
) )
MacroCard( MacroCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.fat), value = stringResource(Res.string.nutrition_grams_format, nutrition.fat),
label = stringResource(Res.string.nutrition_macro_fat), label = stringResource(Res.string.nutrition_macro_fat),
valueColor = colors.macroFat,
) )
MacroCard( MacroCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs), value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs),
label = stringResource(Res.string.nutrition_macro_carbs), label = stringResource(Res.string.nutrition_macro_carbs),
valueColor = colors.macroCarbs,
) )
} }
} }
@@ -76,6 +82,7 @@ fun NutritionSummary(
private fun MacroCard( private fun MacroCard(
value: String, value: String,
label: String, label: String,
valueColor: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = RecipeTheme.colors val colors = RecipeTheme.colors
@@ -91,7 +98,7 @@ private fun MacroCard(
text = value, text = value,
style = style =
RecipeTheme.typography.body.copy( RecipeTheme.typography.body.copy(
color = colors.content, color = valueColor,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = ValueTextSize, fontSize = ValueTextSize,
), ),

View File

@@ -0,0 +1,120 @@
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
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Lucide
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.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,
servingsRange: IntRange,
decrementContentDescription: String,
incrementContentDescription: String,
onServingsChange: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
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(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
StepperButton(
icon = Lucide.Minus,
contentDescription = decrementContentDescription,
enabled = servings > servingsRange.first,
onClick = { onServingsChange(servings - 1) },
)
BasicText(
text = servings.toString(),
style =
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = SERVINGS_VALUE_TEXT_SIZE,
textAlign = TextAlign.Center,
),
modifier = Modifier.width(SERVINGS_VALUE_WIDTH),
)
StepperButton(
icon = Lucide.Plus,
contentDescription = incrementContentDescription,
enabled = servings < servingsRange.last,
onClick = { onServingsChange(servings + 1) },
)
}
}
}
@Composable
private fun StepperButton(
icon: ImageVector,
contentDescription: String,
enabled: Boolean,
onClick: () -> Unit,
) {
val colors = RecipeTheme.colors
UnstyledButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.width(STEPPER_BUTTON_WIDTH).requiredHeight(STEPPER_TAP_TARGET_HEIGHT),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
UnstyledIcon(
imageVector = icon,
contentDescription = contentDescription,
tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f),
modifier = Modifier.size(STEPPER_ICON_SIZE),
)
}
}
}
private val SurfaceBorderWidth = 1.dp
private val STEPPER_HEIGHT = 36.dp
private val STEPPER_TAP_TARGET_HEIGHT = 44.dp
private val STEPPER_BUTTON_WIDTH = 36.dp
private val STEPPER_ICON_SIZE = 14.dp
private val SERVINGS_VALUE_WIDTH = 22.dp
private val SERVINGS_VALUE_TEXT_SIZE = 13.sp

View File

@@ -0,0 +1,11 @@
package dev.ulfrx.recipe.ui.components.recipe
data class RecipeUi(
val id: String,
val title: String,
val cookingMinutes: Int,
val nutrition: RecipeNutritionUi,
val ingredients: List<RecipeIngredientSlotUi>,
val steps: List<String>,
val allowedSlots: List<MealSlot>,
)

View File

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

View File

@@ -0,0 +1,158 @@
package dev.ulfrx.recipe.ui.components.sheet
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.composables.core.BottomSheetScope
import com.composables.core.DragIndication
import com.composables.core.ModalBottomSheet
import com.composables.core.ModalBottomSheetState
import com.composables.core.Scrim
import com.composables.core.Sheet
import com.composables.core.SheetDetent
import com.composables.core.rememberModalBottomSheetState
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.sheet_drag_handle_a11y
@Composable
fun <T : NavKey> RecipeBottomSheet(
state: RecipeBottomSheetState<T>,
modifier: Modifier = Modifier,
entries: EntryProviderScope<T>.() -> Unit,
) {
val modalSheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.Hidden, detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded))
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<T>()
val viewModelDecorator = rememberViewModelStoreNavEntryDecorator<T>()
OpenOrCloseSheetBasedOnVisibility(modalSheetState, state.isOpen)
EmitDismissOnUserCancel(modalSheetState, state)
ModalBottomSheet(state = modalSheetState) {
Scrim(
scrimColor = SCRIM_COLOR,
enter = fadeIn(tween(SCRIM_FADE_MILLIS)),
exit = fadeOut(tween(SCRIM_FADE_MILLIS)),
)
Sheet(
modifier = modifier.fillMaxWidth(),
backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
) {
SheetBody {
if (state.backStack.isNotEmpty()) {
NavDisplay(
backStack = state.backStack,
modifier = Modifier.fillMaxSize(),
onBack = { state.pop() },
entryDecorators = listOf(saveableDecorator, viewModelDecorator),
entryProvider = entryProvider(builder = entries),
)
} else {
Box(modifier = Modifier.fillMaxSize())
}
}
}
}
}
@Composable
private fun BottomSheetScope.SheetBody(content: @Composable BoxScope.() -> Unit) {
val backdrop = rememberGlassBackdropState()
val spacing = RecipeTheme.spacing
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(SHEET_HEIGHT_FRACTION)
.background(RecipeTheme.colors.background),
) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize(), content = content)
}
SheetHandle(
modifier = Modifier.align(Alignment.TopCenter).padding(top = spacing.sm),
)
}
}
}
@Composable
private fun OpenOrCloseSheetBasedOnVisibility(
modalSheetState: ModalBottomSheetState,
visible: Boolean,
) {
LaunchedEffect(visible) {
modalSheetState.targetDetent =
if (visible) SheetDetent.FullyExpanded else SheetDetent.Hidden
}
}
@Composable
private fun <T : NavKey> EmitDismissOnUserCancel(
modalSheetState: ModalBottomSheetState,
state: RecipeBottomSheetState<T>,
) {
LaunchedEffect(modalSheetState.isIdle, modalSheetState.currentDetent) {
if (modalSheetState.isIdle && modalSheetState.currentDetent == SheetDetent.Hidden) {
state.dismiss()
}
}
}
@Composable
private fun BottomSheetScope.SheetHandle(modifier: Modifier = Modifier) {
val colors = RecipeTheme.colors
val label = stringResource(Res.string.sheet_drag_handle_a11y)
DragIndication(
modifier =
modifier
.semantics { this.contentDescription = label }
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface.copy(alpha = HandleAlpha))
.width(HandleWidth)
.height(HandleHeight),
)
}
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 HandleWidth = 36.dp
private val HandleHeight = 5.dp

View File

@@ -0,0 +1,40 @@
package dev.ulfrx.recipe.ui.components.sheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.NavKey
@Stable
class RecipeBottomSheetState<T : NavKey> {
val backStack: SnapshotStateList<T> = mutableStateListOf()
val isOpen by derivedStateOf { backStack.isNotEmpty() }
fun push(entry: T) {
backStack.add(entry)
}
fun pop() {
if (backStack.isNotEmpty()) {
backStack.removeAt(backStack.lastIndex)
}
}
fun open(entry: T) {
backStack.clear()
backStack.add(entry)
}
fun dismiss() {
backStack.clear()
}
}
@Composable
fun <T : NavKey> rememberRecipeBottomSheetState(): RecipeBottomSheetState<T> =
remember { RecipeBottomSheetState() }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,267 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.compose.foundation.background
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_title
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
@Composable
internal fun MealPlanEditorContent(
editing: MealPlanEditorState.Editing,
catalog: List<AddableIngredientUi>,
topChromeInset: Dp,
topChromeHeight: Dp,
onSelectDate: (LocalDate) -> Unit,
onSetCalendarExpanded: (Boolean) -> Unit,
onSelectSlot: (MealSlot) -> Unit,
onSetServings: (Int) -> Unit,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
onRemoveRecipeIngredient: (slotId: String) -> Unit,
onRemoveAddedIngredient: (ingredientId: String) -> Unit,
onRestoreRemoved: () -> Unit,
onAddIngredient: (AddableIngredientUi) -> Unit,
modifier: Modifier = Modifier,
) {
val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState()
var addPanelOpen by rememberSaveable { mutableStateOf(false) }
val navigationInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val keyboardTransition = rememberKeyboardTransitionState()
val keyboardReserve =
when {
addPanelOpen -> maxOf(keyboardTransition.currentInset, keyboardTransition.targetInset)
keyboardTransition.currentInset > navigationInset -> keyboardTransition.currentInset
else -> 0.dp
}
val bottomInset = maxOf(navigationInset, keyboardReserve)
val scaledNutrition =
remember(editing.recipe.nutrition, editing.servings) {
editing.recipe.nutrition.scaledBy(editing.servings)
}
val usedIngredientIds =
remember(editing.addedIngredients) {
editing.addedIngredients.mapTo(mutableSetOf()) { it.ingredientId }
}
Box(modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background)) {
Column(
modifier =
modifier
.fillMaxSize()
.verticalScroll(scrollState, enabled = !addPanelOpen),
) {
Spacer(Modifier.height(topChromeInset))
// Aligns the title row with the floating back/confirm chrome at
// scroll=0: same height, padded inside the chrome's circle pills.
Box(
modifier =
Modifier
.fillMaxWidth()
.height(topChromeHeight)
.padding(horizontal = spacing.lg + topChromeHeight + spacing.sm),
contentAlignment = Alignment.Center,
) {
RecipeTitle(recipeTitle = 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(
recipeTitle: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style =
RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.content,
fontWeight = FontWeight.Medium,
fontSize = RecipeTitleSize,
lineHeight = RecipeTitleLineHeight,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(RecipeTitleGap))
BasicText(
text = recipeTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style =
RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.contentMuted,
fontWeight = FontWeight.Normal,
fontSize = RecipeSubtitleSize,
lineHeight = RecipeSubtitleLineHeight,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
private val RecipeTitleSize = 16.sp
private val RecipeTitleLineHeight = 17.sp
private val RecipeTitleGap = 4.dp
private val RecipeSubtitleSize = 11.sp
private val RecipeSubtitleLineHeight = 14.sp

View File

@@ -0,0 +1,125 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
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.meal_plan_editor_back_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_confirm_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_not_found
@Composable
internal fun MealPlanEditorScreen(
viewModel: MealPlanEditorViewModel,
onBack: () -> Unit,
onConfirm: (PlannedMealUi) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val spacing = RecipeTheme.spacing
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is MealPlanEditorState.Editing -> {
MealPlanEditorContent(
editing = s,
catalog = sampleAddableIngredients,
topChromeInset = TopActionsTopInset,
topChromeHeight = TopPillHeight,
onSelectDate = viewModel::selectDate,
onSetCalendarExpanded = viewModel::setCalendarExpanded,
onSelectSlot = viewModel::selectSlot,
onSetServings = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
onRemoveRecipeIngredient = viewModel::removeRecipeIngredient,
onRemoveAddedIngredient = viewModel::removeAddedIngredient,
onRestoreRemoved = viewModel::restoreRemovedIngredients,
onAddIngredient = viewModel::addIngredient,
)
EditorChromeRow(
showConfirm = true,
onBack = onBack,
onConfirm = { viewModel.confirm()?.let(onConfirm) },
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
)
}
MealPlanEditorState.NotFound -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
BasicText(
text = stringResource(Res.string.meal_plan_editor_not_found),
style = RecipeTheme.typography.body,
)
}
EditorChromeRow(
showConfirm = false,
onBack = onBack,
onConfirm = {},
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
)
}
}
}
}
@Composable
private fun EditorChromeRow(
showConfirm: Boolean,
onBack: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
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,
)
if (showConfirm) {
CircleGlassButton(
onClick = onConfirm,
icon = Lucide.Plus,
contentDescription = stringResource(Res.string.meal_plan_editor_confirm_a11y),
size = TopPillHeight,
iconSize = TopActionIconSize,
glassStyle = RecipeTheme.glass.button,
)
}
}
}
private val TopPillHeight = 44.dp
private val TopActionIconSize = 18.dp
private val TopActionsTopInset = 28.dp

View File

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

View File

@@ -0,0 +1,29 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import kotlinx.datetime.LocalDate
data class AddedIngredientUi(
val ingredientId: String,
val name: String,
val amount: Double,
val unit: String,
)
data class AddableIngredientUi(
val ingredientId: String,
val name: String,
val defaultAmount: Double,
val defaultUnit: String,
)
data class PlannedMealUi(
val id: String,
val recipeId: String,
val date: LocalDate,
val slot: MealSlot,
val servings: Int,
val substitutions: Map<String, String>,
val excludedIngredients: Set<String>,
val addedIngredients: List<AddedIngredientUi>,
)

View File

@@ -0,0 +1,147 @@
package dev.ulfrx.recipe.ui.screens.mealplaneditor
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import dev.ulfrx.recipe.ui.components.recipe.options
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class MealPlanEditorViewModel(
source: MealPlanEditorSource,
recipeProvider: (String) -> RecipeUi?,
plannedMealProvider: (String) -> PlannedMealUi?,
) : ViewModel() {
private val _state = MutableStateFlow(loadInitial(source, recipeProvider, plannedMealProvider))
val state: StateFlow<MealPlanEditorState> = _state.asStateFlow()
fun confirm(): PlannedMealUi? {
val editing = _state.value as? MealPlanEditorState.Editing ?: return null
return PlannedMealUi(
id = editing.id,
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 AddableIngredientUi.toAdded() =
AddedIngredientUi(
ingredientId = ingredientId,
name = name,
amount = defaultAmount,
unit = defaultUnit,
)
}
@OptIn(ExperimentalUuidApi::class)
private fun loadInitial(
source: MealPlanEditorSource,
recipeProvider: (String) -> RecipeUi?,
plannedMealProvider: (String) -> PlannedMealUi?,
): MealPlanEditorState =
when (source) {
is MealPlanEditorSource.NewFromRecipe -> {
val recipe = recipeProvider(source.recipeId)
if (recipe == null) {
MealPlanEditorState.NotFound
} else {
MealPlanEditorState.Editing(
id = "plan_${Uuid.random()}",
recipe = recipe,
selectedDate = todayInSystemTz(),
selectedSlot = recipe.allowedSlots.firstOrNull() ?: MealSlot.entries.first(),
servings = source.initialServings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
substitutions = source.initialSubstitutions.filterValid(recipe),
)
}
}
is MealPlanEditorSource.EditExistingPlan -> {
val planned = plannedMealProvider(source.plannedMealId)
val recipe = planned?.let { recipeProvider(it.recipeId) }
if (planned == null || recipe == null) {
MealPlanEditorState.NotFound
} else {
MealPlanEditorState.Editing(
id = planned.id,
recipe = recipe,
selectedDate = planned.date,
selectedSlot = planned.slot,
servings = planned.servings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
substitutions = planned.substitutions.filterValid(recipe),
excludedIngredients = planned.excludedIngredients,
addedIngredients = planned.addedIngredients,
)
}
}
}
private fun Map<String, String>.filterValid(recipe: RecipeUi): Map<String, String> =
filter { (slotId, optionId) ->
val slot = recipe.ingredients.firstOrNull { it.id == slotId }
slot != null && slot.options.any { it.id == optionId }
}

View File

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

View File

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

View File

@@ -1,62 +1,41 @@
package dev.ulfrx.recipe.ui.screens.planner 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import dev.ulfrx.recipe.ui.components.calendar.RecipeCalendarPill
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.todayInSystemTz import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.DatePeriod import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus 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 @Composable
fun PlannerCalendarPill( fun PlannerCalendarPill(
selectedDate: LocalDate, selectedDate: LocalDate,
expanded: Boolean, expanded: Boolean,
onExpandedChange: (Boolean) -> Unit, onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit, onSelectDate: (LocalDate) -> Unit,
onShiftSelection: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val today = remember { todayInSystemTz() }
val locale = CalendarLocale.PL
val plannedDummy = val plannedDummy =
remember(today) { remember {
val today = todayInSystemTz()
setOf(today, today.plus(DatePeriod(days = 1)), today.plus(DatePeriod(days = 3))) 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, expanded = expanded,
onExpandedChange = onExpandedChange, onExpandedChange = onExpandedChange,
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate, onSelectDate = onSelectDate,
collapsedContent = { onSelectionShift = onShiftSelection,
PlannerWeekStrip( plannedDates = plannedDummy,
selectedDate = selectedDate, modifier = modifier,
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(),
) )
} }

View File

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

View File

@@ -18,7 +18,16 @@ class PlannerViewModel : ViewModel() {
val state: StateFlow<PlannerState> = _state.asStateFlow() val state: StateFlow<PlannerState> = _state.asStateFlow()
fun selectDate(date: LocalDate) { 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) { fun setCalendarOpen(open: Boolean) {

View File

@@ -0,0 +1,189 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
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.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
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.dp
import androidx.compose.ui.unit.sp
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.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeServingsStepper
import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
import dev.ulfrx.recipe.ui.components.section.Section
import dev.ulfrx.recipe.ui.components.section.SectionTitle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.nutrition_label
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
internal fun RecipeDetailContent(
ready: RecipeDetailState.Ready,
onPlanClick: () -> Unit,
onServingsChange: (Int) -> Unit,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
modifier: Modifier = Modifier,
) {
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)) {
NutritionSummary(nutrition = nutrition)
}
}
@Composable
private fun ServingsSection(
servings: Int,
onServingsChange: (Int) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SectionTitle(text = stringResource(Res.string.recipe_detail_servings_label))
RecipeServingsStepper(
servings = servings,
servingsRange = MIN_RECIPE_SERVINGS..MAX_RECIPE_SERVINGS,
decrementContentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
incrementContentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
onServingsChange = onServingsChange,
)
}
}
@Composable
private fun IngredientsSection(
ingredients: List<RecipeIngredientSlotUi>,
servings: Int,
substitutions: Map<String, String>,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
) {
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
IngredientCard {
ingredients.forEachIndexed { index, slot ->
if (index > 0) IngredientDivider()
IngredientRow(
slot = slot.scaledBy(servings),
selectedOptionId = substitutions[slot.id] ?: slot.default.id,
onSelect =
if (slot.alternatives.isNotEmpty()) {
{ choice -> onSelectSubstitution(slot.id, choice.id) }
} else {
null
},
)
}
}
}
}
@Composable
private fun StepsSection(steps: List<String>) {
Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
Column(verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
steps.forEachIndexed { index, step ->
StepRow(number = index + 1, text = step)
}
}
}
}
@Composable
private fun StepRow(
number: Int,
text: String,
) {
val colors = RecipeTheme.colors
Row(horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
BasicText(
text = stringResource(Res.string.recipe_detail_step_number_format, number),
style =
RecipeTheme.typography.body.copy(
color = colors.contentMuted,
fontWeight = FontWeight.Bold,
fontSize = StepNumberTextSize,
),
modifier = Modifier.width(StepNumberWidth),
)
BasicText(
text = text,
style =
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Normal,
fontSize = StepTextSize,
lineHeight = StepLineHeight,
),
modifier = Modifier.weight(1f),
)
}
}
private val StepNumberWidth = 20.dp
private val StepNumberTextSize = 11.sp
private val StepTextSize = 13.sp
private val StepLineHeight = 19.sp

View File

@@ -0,0 +1,176 @@
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Calendar
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.button.CircleButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.meal_plan_editor_title_a11y
import recipe.composeapp.generated.resources.recipe_card_minutes_format
import recipe.composeapp.generated.resources.sample_recipe
@Composable
internal fun RecipeDetailHero(
title: String,
cookingMinutes: Int,
onPlanClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val spacing = RecipeTheme.spacing
Column(
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,
)
.clip(RoundedCornerShape(BANNER_CORNER)),
)
Spacer(Modifier.height(spacing.lg))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.Start,
verticalArrangement =Arrangement.spacedBy(spacing.lg),
) {
BasicText(
text = title,
style =
typography.display.copy(
color = colors.content,
fontSize = TITLE_FONT_SIZE,
lineHeight = TITLE_LINE_HEIGHT,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
),
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
MetaChip(
icon = Lucide.Clock,
text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
)
}
}
PlanButton(onClick = onPlanClick)
}
}
}
@Composable
private fun MetaChip(
icon: ImageVector,
text: String,
) {
val colors = RecipeTheme.colors
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),
) {
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),
)
}
}
@Composable
private fun PlanButton(
onClick: () -> Unit,
) {
CircleButton(
onClick = onClick,
icon = Lucide.Calendar,
contentDescription = stringResource(Res.string.meal_plan_editor_title_a11y),
size = PLAN_BUTTON_SIZE,
iconSize = PLAN_BUTTON_ICON_SIZE,
tint = RecipeTheme.colors.surface,
iconTint = RecipeTheme.colors.accent,
borderTint = RecipeTheme.colors.borderCard,
borderWidth = 1.dp,
)
}
private const val BANNER_ASPECT_RATIO = 16f / 9f
private val BANNER_CORNER = 20.dp
private val BANNER_SHADOW_ELEVATION = 14.dp
private val BANNER_SHADOW_COLOR = Color.Black.copy(alpha = 0.45f)
// Leave room for the sheet handle (8dp top padding + 5dp handle) plus breathing room.
private val HERO_TOP_PADDING = 32.dp
private val TITLE_FONT_SIZE = 19.sp
private val TITLE_LINE_HEIGHT = 20.sp
private val CHIP_SHAPE = RoundedCornerShape(percent = 50)
private val CHIP_PADDING_H = 12.dp
private val CHIP_PADDING_V = 7.dp
private val CHIP_ICON_SIZE = 14.dp
private val CHIP_ICON_GAP = 5.dp
private val PLAN_BUTTON_SIZE = 50.dp
private val PLAN_BUTTON_ICON_SIZE = 25.dp

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.recipe_detail_not_found
@Composable
internal fun RecipeDetailScreen(
viewModel: RecipeDetailViewModel,
onPlan: (RecipeDetailState.Ready) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is RecipeDetailState.Ready ->
RecipeDetailContent(
ready = s,
onPlanClick = { onPlan(s) },
onServingsChange = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
)
RecipeDetailState.NotFound ->
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
BasicText(
text = stringResource(Res.string.recipe_detail_not_found),
style = RecipeTheme.typography.body,
)
}
}
}

View File

@@ -1,509 +0,0 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.core.BottomSheetScope
import com.composables.core.DragIndication
import com.composables.core.ModalBottomSheet
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.Clock
import com.composables.icons.lucide.Lucide
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.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.IngredientRow
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.recipe.scaledBy
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.nutrition_label
import recipe.composeapp.generated.resources.recipe_card_minutes_format
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
import recipe.composeapp.generated.resources.sample_recipe
@Composable
fun RecipeDetailSheet(
viewModel: RecipeDetailViewModel,
onPlanRecipe: (recipeId: String) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val sheetState =
rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
)
val ready = state as? RecipeDetailState.Ready
LaunchedEffect(ready != null) {
sheetState.targetDetent = if (ready != null) 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.
LaunchedEffect(sheetState.isIdle, sheetState.currentDetent) {
if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && ready != null) {
viewModel.dismiss()
}
}
ModalBottomSheet(state = sheetState) {
Scrim(
scrimColor = SCRIM_COLOR,
enter = fadeIn(tween(SCRIM_FADE_MILLIS)),
exit = fadeOut(tween(SCRIM_FADE_MILLIS)),
)
Sheet(
modifier = Modifier.fillMaxWidth(),
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,
)
}
}
}
}
@Composable
private fun BottomSheetScope.RecipeDetailContent(
ready: RecipeDetailState.Ready,
onServingsChange: (Int) -> Unit,
onSelectSubstitution: (String, String) -> Unit,
onPlanRecipe: (String) -> Unit,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
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
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
RecipeHero()
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
BasicText(
text = detail.title,
style =
typography.display.copy(
color = colors.content,
fontSize = TITLE_TEXT_SIZE,
lineHeight = TITLE_LINE_HEIGHT,
fontWeight = FontWeight.Bold,
),
)
Spacer(Modifier.height(spacing.sm))
MetaRow(minutes = detail.cookingMinutes)
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))
}
}
}
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),
)
PlanButton(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = spacing.lg, end = spacing.lg),
onClick = { onPlanRecipe(detail.id) },
)
}
}
}
@Composable
private fun RecipeHero() {
val colors = RecipeTheme.colors
Box(modifier = Modifier.fillMaxWidth().height(HERO_HEIGHT)) {
Box(modifier = Modifier.fillMaxSize().background(colors.surfaceGlass))
Image(
painter = painterResource(Res.drawable.sample_recipe),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier =
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops =
arrayOf(
0.5f to Color.Transparent,
1f to colors.background,
),
),
),
)
}
}
@Composable
private fun MetaRow(minutes: Int) {
val colors = RecipeTheme.colors
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
UnstyledIcon(
imageVector = Lucide.Clock,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(META_ICON_SIZE),
)
BasicText(
text = stringResource(Res.string.recipe_card_minutes_format, minutes),
style = RecipeTheme.typography.label.copy(color = colors.contentMuted),
)
}
}
@Composable
private fun Section(
title: String,
content: @Composable () -> Unit,
) {
SectionTitle(text = title)
Spacer(Modifier.height(RecipeTheme.spacing.lg))
content()
}
@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,
),
)
}
@Composable
private fun NutritionSection(nutrition: RecipeNutritionUi) {
Section(title = stringResource(Res.string.nutrition_label)) {
NutritionSummary(nutrition = nutrition)
}
}
@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))
ServingsStepper(
servings = servings,
onDecrement = { onServingsChange(servings - 1) },
onIncrement = { onServingsChange(servings + 1) },
)
}
}
@Composable
private fun IngredientsSection(
ingredients: List<RecipeIngredientSlotUi>,
servings: Int,
substitutions: Map<String, String>,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
) {
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
Column(verticalArrangement = Arrangement.spacedBy(INGREDIENT_ROW_GAP)) {
ingredients.forEach { slot ->
IngredientRow(
slot = slot.scaledBy(servings),
selectedOptionId = substitutions[slot.id] ?: slot.default.id,
onSelect =
if (slot.alternatives.isNotEmpty()) {
{ choice -> onSelectSubstitution(slot.id, choice.id) }
} else {
null
},
)
}
}
}
}
@Composable
private fun StepsSection(steps: List<String>) {
Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
Column(verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
steps.forEachIndexed { index, step ->
StepRow(number = index + 1, text = step)
}
}
}
}
@Composable
private fun ServingsStepper(
servings: Int,
onDecrement: () -> Unit,
onIncrement: () -> Unit,
) {
val colors = RecipeTheme.colors
Row(
modifier =
Modifier
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface)
.padding(horizontal = RecipeTheme.spacing.xs),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
StepperButton(
icon = Lucide.Minus,
contentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
enabled = servings > MIN_RECIPE_SERVINGS,
onClick = onDecrement,
)
BasicText(
text = servings.toString(),
style =
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
),
modifier = Modifier.width(SERVINGS_VALUE_WIDTH),
)
StepperButton(
icon = Lucide.Plus,
contentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
enabled = servings < MAX_RECIPE_SERVINGS,
onClick = onIncrement,
)
}
}
@Composable
private fun StepperButton(
icon: ImageVector,
contentDescription: String,
enabled: Boolean,
onClick: () -> Unit,
) {
val colors = RecipeTheme.colors
UnstyledButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(STEPPER_BUTTON_SIZE),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
UnstyledIcon(
imageVector = icon,
contentDescription = contentDescription,
tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f),
modifier = Modifier.size(STEPPER_ICON_SIZE),
)
}
}
}
@Composable
private fun StepRow(
number: Int,
text: String,
) {
val colors = RecipeTheme.colors
Row(horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
BasicText(
text = stringResource(Res.string.recipe_detail_step_number_format, number),
style =
RecipeTheme.typography.body.copy(
color = colors.contentMuted,
fontWeight = FontWeight.Medium,
fontSize = STEP_TEXT_SIZE,
),
modifier = Modifier.width(STEP_NUMBER_WIDTH),
)
BasicText(
text = text,
style =
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Normal,
fontSize = STEP_TEXT_SIZE,
lineHeight = STEP_LINE_HEIGHT,
),
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 const val SHEET_HEIGHT_FRACTION = 0.92f
private const val SCRIM_FADE_MILLIS = 250
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
private val SHEET_CORNER_RADIUS = 28.dp
private val HERO_HEIGHT = 200.dp
private val HANDLE_WIDTH = 36.dp
private val HANDLE_HEIGHT = 5.dp
private val INGREDIENT_ROW_GAP = 6.dp
private val META_ICON_SIZE = 14.dp
private val STEPPER_BUTTON_SIZE = 30.dp
private val STEPPER_ICON_SIZE = 14.dp
private val SERVINGS_VALUE_WIDTH = 28.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 TITLE_TEXT_SIZE = 24.sp
private val TITLE_LINE_HEIGHT = 28.sp
private val SECTION_HEADER_TEXT_SIZE = 11.sp
private val SECTION_HEADER_TRACKING = 1.sp
private val STEP_TEXT_SIZE = 14.sp
private val STEP_LINE_HEIGHT = 20.sp

View File

@@ -1,13 +1,15 @@
package dev.ulfrx.recipe.ui.screens.recipedetail package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
internal const val MIN_RECIPE_SERVINGS = 1 internal const val MIN_RECIPE_SERVINGS = 1
internal const val MAX_RECIPE_SERVINGS = 12 internal const val MAX_RECIPE_SERVINGS = 12
sealed interface RecipeDetailState { sealed interface RecipeDetailState {
data object Hidden : RecipeDetailState data object NotFound : RecipeDetailState
data class Ready( data class Ready(
val recipe: RecipeDetailUi, val recipe: RecipeUi,
val servings: Int = MIN_RECIPE_SERVINGS, val servings: Int = MIN_RECIPE_SERVINGS,
val substitutions: Map<String, String> = emptyMap(), val substitutions: Map<String, String> = emptyMap(),
) : RecipeDetailState ) : RecipeDetailState

View File

@@ -1,13 +0,0 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
data class RecipeDetailUi(
val id: String,
val title: String,
val cookingMinutes: Int,
val nutrition: RecipeNutritionUi,
val ingredients: List<RecipeIngredientSlotUi>,
val steps: List<String>,
)

View File

@@ -7,18 +7,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
class RecipeDetailViewModel : ViewModel() { class RecipeDetailViewModel(recipeId: String) : ViewModel() {
private val _state = MutableStateFlow<RecipeDetailState>(RecipeDetailState.Hidden) private val _state = MutableStateFlow(sampleRecipe(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.NotFound)
val state: StateFlow<RecipeDetailState> = _state.asStateFlow() val state: StateFlow<RecipeDetailState> = _state.asStateFlow()
fun open(recipeId: String) {
_state.value = sampleRecipeDetail(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.Hidden
}
fun dismiss() {
_state.value = RecipeDetailState.Hidden
}
fun setServings(value: Int) = fun setServings(value: Int) =
_state.update { current -> _state.update { current ->
if (current is RecipeDetailState.Ready) { if (current is RecipeDetailState.Ready) {

View File

@@ -1,16 +1,23 @@
package dev.ulfrx.recipe.ui.screens.recipedetail package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
internal val sampleRecipeDetails: Map<String, RecipeDetailUi> = 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 sampleRecipes: Map<String, RecipeUi> =
listOf( listOf(
RecipeDetailUi( RecipeUi(
id = "rcp_nalesniki", id = "rcp_nalesniki",
title = "Naleśniki z twarogiem", title = "Naleśniki z twarogiem",
cookingMinutes = 25, cookingMinutes = 25,
nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42), nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
ingredients = ingredients =
listOf( listOf(
slot("Mąka pszenna", 60.0, "g"), slot("Mąka pszenna", 60.0, "g"),
@@ -34,11 +41,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Nałóż masę twarogową na naleśniki, zwiń i podawaj.", "Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_owsianka", id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami", title = "Owsianka z owocami i orzechami",
cookingMinutes = 10, cookingMinutes = 10,
nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38), nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38),
allowedSlots = BreakfastOrSnack,
ingredients = ingredients =
listOf( listOf(
slot("Płatki owsiane", 50.0, "g"), slot("Płatki owsiane", 50.0, "g"),
@@ -74,11 +82,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Polej miodem i podawaj.", "Polej miodem i podawaj.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_spaghetti", id = "rcp_spaghetti",
title = "Spaghetti bolognese", title = "Spaghetti bolognese",
cookingMinutes = 40, cookingMinutes = 40,
nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65), nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Makaron spaghetti", 100.0, "g"), slot("Makaron spaghetti", 100.0, "g"),
@@ -102,11 +111,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Wymieszaj sos z odsączonym makaronem i podawaj.", "Wymieszaj sos z odsączonym makaronem i podawaj.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_pierogi", id = "rcp_pierogi",
title = "Pierogi ruskie", title = "Pierogi ruskie",
cookingMinutes = 90, cookingMinutes = 90,
nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68), nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Mąka pszenna", 120.0, "g"), slot("Mąka pszenna", 120.0, "g"),
@@ -125,11 +135,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Podawaj okraszone masłem i podsmażoną cebulą.", "Podawaj okraszone masłem i podsmażoną cebulą.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_kanapka_awokado", id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem", title = "Kanapka z awokado i jajkiem",
cookingMinutes = 5, cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16), nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Lunch, MealSlot.Supper, MealSlot.Snack),
ingredients = ingredients =
listOf( listOf(
slot("Pieczywo razowe", 1.0, "kromka"), slot("Pieczywo razowe", 1.0, "kromka"),
@@ -146,11 +157,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Ułóż plastry jajka i posyp szczypiorkiem.", "Ułóż plastry jajka i posyp szczypiorkiem.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_schabowy", id = "rcp_schabowy",
title = "Schabowy z ziemniakami", title = "Schabowy z ziemniakami",
cookingMinutes = 60, cookingMinutes = 60,
nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62), nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Schab", 150.0, "g"), slot("Schab", 150.0, "g"),
@@ -169,11 +181,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Podawaj z ziemniakami i ulubioną surówką.", "Podawaj z ziemniakami i ulubioną surówką.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_salatka_grecka", id = "rcp_salatka_grecka",
title = "Sałatka grecka", title = "Sałatka grecka",
cookingMinutes = 15, cookingMinutes = 15,
nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12), nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12),
allowedSlots = LightMeal,
ingredients = ingredients =
listOf( listOf(
slot("Pomidory", 150.0, "g"), slot("Pomidory", 150.0, "g"),
@@ -191,11 +204,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.", "Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_pomidorowa", id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem", title = "Zupa pomidorowa z ryżem",
cookingMinutes = 35, cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39), nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Passata pomidorowa", 200.0, "ml"), slot("Passata pomidorowa", 200.0, "ml"),
@@ -212,11 +226,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.", "Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_kurczak_curry", id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati", title = "Kurczak curry z ryżem basmati",
cookingMinutes = 45, cookingMinutes = 45,
nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70), nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Pierś z kurczaka", 150.0, "g"), slot("Pierś z kurczaka", 150.0, "g"),
@@ -235,11 +250,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Podawaj z ryżem basmati.", "Podawaj z ryżem basmati.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_jajecznica", id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem", title = "Jajecznica na maśle ze szczypiorkiem",
cookingMinutes = 8, cookingMinutes = 8,
nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3), nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
ingredients = ingredients =
listOf( listOf(
slot("Jajka", 3.0, "szt."), slot("Jajka", 3.0, "szt."),
@@ -254,11 +270,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Posyp posiekanym szczypiorkiem i podawaj.", "Posyp posiekanym szczypiorkiem i podawaj.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_risotto", id = "rcp_risotto",
title = "Risotto z grzybami leśnymi", title = "Risotto z grzybami leśnymi",
cookingMinutes = 35, cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66), nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Ryż arborio", 80.0, "g"), slot("Ryż arborio", 80.0, "g"),
@@ -276,11 +293,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.", "Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_tortilla", id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami", title = "Tortilla z kurczakiem i warzywami",
cookingMinutes = 20, cookingMinutes = 20,
nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48), nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48),
allowedSlots = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper),
ingredients = ingredients =
listOf( listOf(
slot("Tortilla pszenna", 1.0, "szt."), slot("Tortilla pszenna", 1.0, "szt."),
@@ -298,11 +316,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.", "Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_smoothie", id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe", title = "Smoothie bananowo-szpinakowe",
cookingMinutes = 5, cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33), nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33),
allowedSlots = BreakfastOrSnack,
ingredients = ingredients =
listOf( listOf(
slot("Banan", 1.0, "szt."), slot("Banan", 1.0, "szt."),
@@ -323,11 +342,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Przelej do szklanki i podawaj od razu.", "Przelej do szklanki i podawaj od razu.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_losos", id = "rcp_losos",
title = "Łosoś pieczony z brokułami", title = "Łosoś pieczony z brokułami",
cookingMinutes = 30, cookingMinutes = 30,
nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12), nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Filet z łososia", 150.0, "g"), slot("Filet z łososia", 150.0, "g"),
@@ -344,11 +364,12 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
"Piecz łososia i brokuły na blasze ok. 1518 minut.", "Piecz łososia i brokuły na blasze ok. 1518 minut.",
), ),
), ),
RecipeDetailUi( RecipeUi(
id = "rcp_nadziewane_papryki", id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami", title = "Papryki nadziewane kaszą i warzywami",
cookingMinutes = 55, cookingMinutes = 55,
nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58), nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58),
allowedSlots = LunchOrDinner,
ingredients = ingredients =
listOf( listOf(
slot("Papryka", 2.0, "szt."), slot("Papryka", 2.0, "szt."),
@@ -369,7 +390,7 @@ internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
), ),
).associateBy { it.id } ).associateBy { it.id }
internal fun sampleRecipeDetail(id: String): RecipeDetailUi? = sampleRecipeDetails[id] internal fun sampleRecipe(id: String): RecipeUi? = sampleRecipes[id]
private fun slot( private fun slot(
name: String, name: String,

View File

@@ -0,0 +1,50 @@
package dev.ulfrx.recipe.ui.screens.recipesheet
import androidx.compose.runtime.Composable
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
import dev.ulfrx.recipe.navigation.Screen
import dev.ulfrx.recipe.ui.components.sheet.RecipeBottomSheet
import dev.ulfrx.recipe.ui.components.sheet.RecipeBottomSheetState
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorScreen
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailScreen
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun RecipeSheet(state: RecipeBottomSheetState<Screen>) {
RecipeBottomSheet(state = state) {
entry<Screen.RecipeDetail> { key ->
val vm: RecipeDetailViewModel = koinViewModel { parametersOf(key.recipeId) }
RecipeDetailScreen(
viewModel = vm,
onPlan = { ready ->
state.push(
Screen.MealPlanEditor.Open(
MealPlanEditorSource.NewFromRecipe(
recipeId = ready.recipe.id,
initialServings = ready.servings,
initialSubstitutions = ready.substitutions,
),
),
)
},
)
}
entry<Screen.MealPlanEditor.Open> { key ->
val vm: MealPlanEditorViewModel = koinViewModel { parametersOf(key.source) }
MealPlanEditorScreen(
viewModel = vm,
onBack = state::pop,
onConfirm = { _ ->
// TODO Phase 6: persist via PlannedMealsRepository
state.dismiss()
},
)
}
}
}

View File

@@ -14,13 +14,15 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.navigation.Screen
import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet import dev.ulfrx.recipe.ui.components.sheet.rememberRecipeBottomSheetState
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel import dev.ulfrx.recipe.ui.screens.recipesheet.RecipeSheet
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle
import recipe.composeapp.generated.resources.search_screen_empty_results_title import recipe.composeapp.generated.resources.search_screen_empty_results_title
@@ -28,13 +30,12 @@ import recipe.composeapp.generated.resources.search_screen_empty_results_title
@Composable @Composable
fun SearchScreen( fun SearchScreen(
viewModel: ShellSearchViewModel, viewModel: ShellSearchViewModel,
catalogViewModel: RecipeCatalogViewModel,
detailViewModel: RecipeDetailViewModel,
catalogGridState: LazyGridState, catalogGridState: LazyGridState,
onPlanRecipe: (String) -> Unit = {},
) { ) {
val catalogViewModel: RecipeCatalogViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle() val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
val bottomSheetState = rememberRecipeBottomSheetState<Screen>()
Box( Box(
modifier = modifier =
@@ -59,15 +60,12 @@ fun SearchScreen(
} else { } else {
RecipeCatalogGrid( RecipeCatalogGrid(
state = catalogState, state = catalogState,
onRecipeClick = detailViewModel::open, onRecipeClick = { bottomSheetState.open(Screen.RecipeDetail(it)) },
gridState = catalogGridState, gridState = catalogGridState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
RecipeDetailSheet( RecipeSheet(state = bottomSheetState)
viewModel = detailViewModel,
onPlanRecipe = onPlanRecipe,
)
} }
} }

View File

@@ -25,10 +25,8 @@ import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.search.SearchScreen import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -37,8 +35,6 @@ import org.koin.compose.viewmodel.koinViewModel
fun AppShell(modifier: Modifier = Modifier) { fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() } val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel() val searchVm: ShellSearchViewModel = koinViewModel()
val catalogVm: RecipeCatalogViewModel = koinViewModel()
val detailVm: RecipeDetailViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState() val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle() val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState() val backdropState = rememberGlassBackdropState()
@@ -70,8 +66,6 @@ fun AppShell(modifier: Modifier = Modifier) {
if (searchOpen) { if (searchOpen) {
SearchScreen( SearchScreen(
viewModel = searchVm, viewModel = searchVm,
catalogViewModel = catalogVm,
detailViewModel = detailVm,
catalogGridState = catalogGridState, catalogGridState = catalogGridState,
) )
} else { } else {

View File

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

View File

@@ -94,7 +94,7 @@ private fun DockTabItem(
) { ) {
val label = stringResource(destination.labelRes) val label = stringResource(destination.labelRes)
val a11yLabel = if (isActive) "$label, aktywna" else label 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( Box(
modifier = modifier =
modifier.semantics { modifier.semantics {

View File

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

View File

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

View File

@@ -2,15 +2,10 @@ package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.graphics.Color 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( public data class RecipeColors(
val background: Color, val background: Color,
val surface: Color, val surface: Color,
val surfaceGlass: Color, val surfaceGlass: Color,
val surfaceGlassOverlay: Color,
val content: Color, val content: Color,
val contentMuted: Color, val contentMuted: Color,
val accent: Color, val accent: Color,
@@ -18,14 +13,16 @@ public data class RecipeColors(
val separator: Color, val separator: Color,
val borderCard: Color, val borderCard: Color,
val destructive: Color, val destructive: Color,
val macroProtein: Color,
val macroFat: Color,
val macroCarbs: Color,
) )
public val LightRecipeColors: RecipeColors = public val LightRecipeColors: RecipeColors =
RecipeColors( RecipeColors(
background = Color(0xFFEAE6DF), background = Color(0xFFEAE6DF),
surface = Color(0xFFFFFFFF), surface = Color(0xFFFFFFFF),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f), surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.6f),
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.20f),
content = Color(0xFF0F1113), content = Color(0xFF0F1113),
contentMuted = Color(0xFF6B6E73), contentMuted = Color(0xFF6B6E73),
accent = Color(0xFFD97757), accent = Color(0xFFD97757),
@@ -33,19 +30,24 @@ public val LightRecipeColors: RecipeColors =
separator = Color(0xFFE5E1DA), separator = Color(0xFFE5E1DA),
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f), borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
destructive = Color(0xFFC0392B), destructive = Color(0xFFC0392B),
macroProtein = Color(0xFF3B82F6),
macroFat = Color(0xFFD97706),
macroCarbs = Color(0xFFEA580C),
) )
public val DarkRecipeColors: RecipeColors = public val DarkRecipeColors: RecipeColors =
RecipeColors( RecipeColors(
background = Color(0xFF1E2024), background = Color(0xFF1E2024),
surface = Color(0xFF2A2D31), surface = Color(0xFF2A2D31),
surfaceGlass = Color(0xFF494D53).copy(alpha = 0.55f), surfaceGlass = Color(0xFF313439).copy(alpha = 0.65f),
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.12f),
content = Color(0xFFF1EFEA), content = Color(0xFFF1EFEA),
contentMuted = Color(0xFF9AA0A6), contentMuted = Color(0xFF9AA0A6),
accent = Color(0xFFE48A6E), accent = Color(0xFFFC8964),
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f), chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
separator = Color(0xFF383B40), separator = Color(0xFF383B40),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f), borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
destructive = Color(0xFFE57368), destructive = Color(0xFFE57368),
macroProtein = Color(0xFF60A5FA),
macroFat = Color(0xFFFBBF24),
macroCarbs = Color(0xFFFB923C),
) )

View File

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

View File

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

View File

@@ -1,81 +0,0 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RecipeDetailViewModelTest {
@Test
fun openKnownRecipeShowsFreshState() {
val viewModel = RecipeDetailViewModel()
viewModel.open("rcp_nalesniki")
val state = viewModel.readyState()
assertEquals("rcp_nalesniki", state.recipe.id)
assertEquals(MIN_RECIPE_SERVINGS, state.servings)
assertTrue(state.substitutions.isEmpty())
}
@Test
fun openUnknownRecipeClearsPreviousState() {
val viewModel = RecipeDetailViewModel()
viewModel.open("rcp_nalesniki")
viewModel.open("missing")
assertEquals(RecipeDetailState.Hidden, viewModel.state.value)
}
@Test
fun servingsAreClampedToSupportedRange() {
val viewModel = RecipeDetailViewModel()
viewModel.open("rcp_nalesniki")
viewModel.setServings(0)
assertEquals(MIN_RECIPE_SERVINGS, viewModel.readyState().servings)
viewModel.setServings(MAX_RECIPE_SERVINGS + 1)
assertEquals(MAX_RECIPE_SERVINGS, viewModel.readyState().servings)
}
@Test
fun substitutionCanBeSelectedAndResetToDefault() {
val viewModel = RecipeDetailViewModel()
viewModel.open("rcp_nalesniki")
val slot =
viewModel
.readyState()
.recipe.ingredients
.first { it.alternatives.isNotEmpty() }
val alternative = slot.alternatives.first()
viewModel.selectSubstitution(slot.id, alternative.id)
assertEquals(alternative.id, viewModel.readyState().substitutions[slot.id])
viewModel.selectSubstitution(slot.id, slot.default.id)
assertFalse(slot.id in viewModel.readyState().substitutions)
}
@Test
fun invalidSubstitutionIsIgnored() {
val viewModel = RecipeDetailViewModel()
viewModel.open("rcp_nalesniki")
val slot =
viewModel
.readyState()
.recipe.ingredients
.first { it.alternatives.isNotEmpty() }
viewModel.selectSubstitution(slot.id, "missing")
assertTrue(viewModel.readyState().substitutions.isEmpty())
}
private fun RecipeDetailViewModel.readyState(): RecipeDetailState.Ready {
val state = state.value
assertTrue(state is RecipeDetailState.Ready)
return state
}
}

View File

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

View File

@@ -92,6 +92,7 @@ lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref =
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" } compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" } compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" } liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }