Compare commits

...

11 Commits

Author SHA1 Message Date
11ea98e452 Reorganise bottom sheet, recipe detail and meal plan editor 2026-06-05 23:16:05 +02:00
bcd9b329c5 Redesign recipe detail view 2026-06-04 23:27:36 +02:00
4dd8ef5f8a Fix meal planner editor 2026-06-04 22:16:55 +02:00
d1916d3fe6 Add meal plan editor + smaller changes 2026-06-04 17:24:33 +02:00
121f79109a Redesign recipe detail screen 2026-05-30 19:56:49 +02:00
22b43050d6 Implement calendar pill widgets 2026-05-28 23:12:53 +02:00
579504b927 Change dark theme colors 2026-05-26 22:54:13 +02:00
c017a8e777 Add recipe detail 2026-05-26 22:39:35 +02:00
6d38b8b775 Add recipe catalog view 2026-05-22 15:22:33 +02:00
ae4186d9fa Collapse PlanEntry customization into a materialized ingredient snapshot
PlanCustomization / IngredientCustomization / AddedIngredient disappear;
PlanEntry now carries List<PlanIngredient> directly. Substitutions,
exclusions, amount overrides, product picks, and added ingredients are
all just whatever ends up in the list. Recipe edits no longer mutate
historic plan entries — load-bearing once consumption tracking lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 23:07:02 +02:00
2d2556fd26 Reshape shared/commonMain domain model
Replace the 11 hand-rolled model files with 7 grouped by concern. Typed
ID value classes kill bare-string FKs. Canonical 3-value MeasurementUnit
enum kills the runtime unitMismatch class — Polish vocabulary lives in
Quantity.displayHint as render-only metadata. MealExtras (5 maps) collapses
into IngredientCustomization + PlanCustomization. IngredientCategory and
MealSlot become household-scoped entities with LocalizedString names so
they're customizable without an app release. Display names land as
LocalizedString from day one; no Polish strings in identifiers or wire
codes. Recipe drops allowedSlots — slot affinity is a UI-layer match on
Recipe.tags vs MealSlot.name. Skip is absence, not a sealed sibling.

Plan: ~/.claude/plans/i-have-generated-some-inherited-conway.md.

Covered by SerializationRoundTripTest: 12 assertions across typed-ID
inlining, MeasurementUnit wire format, LocalizedString JSON shape, full
PlanEntry round-trip with every customization kind, SyncMeta tombstone
omission, and Catalog defaults handling. All targets compile and pass:
JVM, Android (debug + release), iOS Simulator Arm64.
2026-05-19 23:11:05 +02:00
96 changed files with 6122 additions and 488 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

@@ -74,14 +74,16 @@ dev.ulfrx.recipe/
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab) ├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
├── ui/ ├── ui/
│ ├── theme/ # Colors, typography, Liquid glass style tokens │ ├── theme/ # Colors, typography, Liquid glass style tokens
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful │ ├── components/ # Shared, stateless (VM-free) Recipe-styled composables built on Compose Unstyled where useful
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel │ └── screens/{recipes,planner,pantry,shopping,recipedetail}/ # Each with screen + ViewModel
├── data/{local,remote,repository}/ ├── data/{local,remote,repository}/
└── domain/ # Client-only logic; shared/ handles cross-cutting └── domain/ # Client-only logic; shared/ handles cross-cutting
``` ```
**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above. **Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above.
**Rule:** A `screens/` package is a *stateful* UI feature (screen + ViewModel), not necessarily a nav route. `recipedetail` presents as a modal bottom sheet and is opened from multiple hosts (search, later planner) — it lives under `screens/` because it owns a ViewModel, while its leaf widgets (`IngredientRow`, `NutritionSummary`) stay in `components/`, which is reserved for stateless, VM-free composables.
## Non-negotiable conventions ## Non-negotiable conventions
1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor. 1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor.

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)
@@ -108,6 +109,9 @@ kotlin {
// ASWebAuthenticationSession integration directly from Kotlin. // ASWebAuthenticationSession integration directly from Kotlin.
implementation(libs.ktor.clientDarwin) implementation(libs.ktor.clientDarwin)
} }
commonTest.dependencies {
implementation(libs.kotlin.test)
}
} }
} }

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

View File

@@ -22,12 +22,36 @@
<!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) --> <!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
<string name="search_placeholder">Szukaj…</string> <string name="search_placeholder">Szukaj…</string>
<!-- Phase 2.1 — Search screen scaffolding (curated landing in State B; results in State C) --> <!-- Phase 2.1 — Search screen scaffolding (results in State C) -->
<string name="search_screen_curated_title">Tu pojawią się szybkie skróty</string>
<string name="search_screen_curated_subtitle">Sugestie wyszukiwania pojawią się wraz z rozwojem aplikacji.</string>
<string name="search_screen_empty_results_title">Brak wyników</string> <string name="search_screen_empty_results_title">Brak wyników</string>
<string name="search_screen_empty_results_subtitle">Zacznij pisać, aby wyszukać.</string> <string name="search_screen_empty_results_subtitle">Zacznij pisać, aby wyszukać.</string>
<!-- Phase 2.1 — Recipe catalog card meta (Phase 11 polishes copy + plurals) -->
<string name="recipe_card_minutes_format">%1$d min</string>
<string name="recipe_card_kcal_format">%1$d kcal</string>
<!-- Phase 2.1 — Nutrition facts widget (reusable across recipe detail, planner, …) -->
<string name="nutrition_label">Wartości odżywcze</string>
<string name="nutrition_macro_kcal">kcal</string>
<string name="nutrition_macro_protein">białko</string>
<string name="nutrition_macro_fat">tłuszcz</string>
<string name="nutrition_macro_carbs">węglowodany</string>
<string name="nutrition_grams_format">%1$dg</string>
<!-- Phase 2.1 — Ingredient row widget (reusable across recipe detail, planner, …) -->
<string name="ingredient_substitute_a11y">Zamień składnik</string>
<!-- Phase 2.1 — Recipe detail sheet (UI-only view; planner wiring lands in later phases) -->
<string name="recipe_detail_servings_label">Porcje</string>
<string name="recipe_detail_section_ingredients">Składniki</string>
<string name="recipe_detail_section_steps">Kroki</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_increment_a11y">Zwiększ liczbę porcji</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>
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string> <string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
@@ -45,4 +69,37 @@
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string> <string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string> <string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string> <string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
<!-- Bottom calendar pill (planer / spiżarnia / zakupy) -->
<string name="calendar_horizon_today">Tylko dziś</string>
<string name="calendar_horizon_days">Najbliższe %1$d dni</string>
<!-- Dummy metryki pilla (UI-first; realne dane w fazach 8/9) -->
<string name="pantry_shortfall_count">%1$d braków</string>
<string name="shopping_buy_count">%1$d do kupienia</string>
<!-- Pory posiłku — wspólne dla detalu i edytora planu (Phase 6 polishes copy + plurals) -->
<string name="meal_slot_breakfast">Śniadanie</string>
<string name="meal_slot_lunch">Lunch</string>
<string name="meal_slot_dinner">Obiad</string>
<string name="meal_slot_supper">Kolacja</string>
<string name="meal_slot_snack">Przekąska</string>
<!-- Phase 6 — Meal plan editor (UI-first; planStore wiring lands in later phases) -->
<string name="meal_plan_editor_title">Zaplanuj posiłek</string>
<string name="meal_plan_editor_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,27 +1,37 @@
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.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.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
val shellModule = val shellModule =
module { module {
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
// Active-tab tracking lives in TabNavigator (a `remember`-scoped state holder
// owned by AppShell), not in a shell-level VM, so there is no ShellViewModel
// to register.
viewModel<HomeViewModel>() viewModel<HomeViewModel>()
viewModel<PlannerViewModel>() viewModel<PlannerViewModel>()
viewModel<PantryViewModel>() viewModel<PantryViewModel>()
viewModel<ShoppingViewModel>() viewModel<ShoppingViewModel>()
// Shell-wide search VM — single global state machine (closed / open
// unfocused / open focused) shared by the SearchScreen body and the
// SearchPillRow chrome. Per-tab SearchViewModels were retired when search
// moved from per-tab inline overlay to a shell-level destination.
viewModel<ShellSearchViewModel>() viewModel<ShellSearchViewModel>()
viewModel<RecipeCatalogViewModel>()
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

@@ -45,9 +45,8 @@ import org.koin.compose.viewmodel.koinViewModel
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour * during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
* (`saveState=true`/`restoreState=true`). * (`saveState=true`/`restoreState=true`).
* *
* Phase 5+ introduces detail screens with their own VM scopes; at that point * Recipe detail is a modal bottom sheet (not a nav destination), so it needs no
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries * per-entry VM scope here; its VM is hosted by the surface that opens it.
* specifically (passed via `entryDecorators = listOf(...)`).
* *
* ## Search note * ## Search note
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not * Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not

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

@@ -2,12 +2,13 @@ package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.background 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.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
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.offset
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.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -15,20 +16,17 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable 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.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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.dp
import androidx.compose.ui.unit.sp
import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
/**
* Single day cell — circle, optional outline ring for "today", optional fill
* for "selected", optional dot indicator below the date number. Disabled days
* render as a non-interactive box.
*/
@Composable @Composable
internal fun CalendarDayCell( internal fun CalendarDayCell(
date: LocalDate, date: LocalDate,
@@ -37,48 +35,56 @@ internal fun CalendarDayCell(
isToday: Boolean, isToday: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
numberStyle: TextStyle = RecipeTheme.typography.label.copy(fontWeight = FontWeight.Light),
cellHeight: Dp = 36.dp,
header: String? = null,
headerStyle: TextStyle =
RecipeTheme.typography.label.copy(
fontWeight = FontWeight.Light,
fontSize = 9.sp,
lineHeight = 10.sp,
),
) { ) {
val colors = RecipeTheme.colors val colors = RecipeTheme.colors
val baseColor = colors.content val baseColor = colors.content
val mutedColor = colors.contentMuted val mutedColor = colors.contentMuted
val accent = colors.accent val accent = colors.accent
val background: Color = val background = if (isSelected) accent.copy(alpha = 0.18f) else Color.Transparent
when { val textColor =
isSelected -> accent.copy(alpha = 0.18f)
else -> Color.Transparent
}
val textColor: Color =
when { when {
state.disabled -> mutedColor.copy(alpha = 0.45f) state.disabled -> mutedColor.copy(alpha = 0.45f)
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f) state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
isSelected -> accent isSelected -> accent
else -> baseColor else -> baseColor
} }
val ringColor: Color = val headerColor =
if (isSelected || state.dimmed || state.disabled) textColor else mutedColor
val ringColor =
when { when {
isSelected -> accent.copy(alpha = 0.55f) isSelected -> accent.copy(alpha = 0.55f)
isToday -> baseColor.copy(alpha = 0.35f) isToday -> baseColor.copy(alpha = 0.35f)
else -> Color.Transparent else -> Color.Transparent
} }
val indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = INDICATOR_MUTED_ALPHA)
val cellModifier = val cellModifier = modifier.height(cellHeight).fillMaxWidth()
modifier val isClickable = LocalCalendarInteractive.current && !state.disabled
.height(36.dp)
.fillMaxWidth()
if (state.disabled) { val content: @Composable () -> Unit = {
Box(modifier = cellModifier, contentAlignment = Alignment.Center) {
DayCellInner( DayCellInner(
date = date, date = date,
textColor = textColor, textColor = textColor,
numberStyle = numberStyle,
header = header,
headerStyle = headerStyle,
headerColor = headerColor,
indicator = state.indicator, indicator = state.indicator,
indicatorColor = mutedColor.copy(alpha = 0.5f), indicatorColor = indicatorColor,
) )
} }
return
}
if (isClickable) {
UnstyledButton( UnstyledButton(
onClick = onClick, onClick = onClick,
backgroundColor = background, backgroundColor = background,
@@ -87,12 +93,13 @@ internal fun CalendarDayCell(
borderColor = ringColor, borderColor = ringColor,
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp, borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
modifier = cellModifier, modifier = cellModifier,
) { content = { content() },
DayCellInner( )
date = date, } else {
textColor = textColor, Box(
indicator = state.indicator, modifier = cellModifier.dayCellSurface(background, ringColor),
indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = 0.65f), contentAlignment = Alignment.Center,
content = { content() },
) )
} }
} }
@@ -101,31 +108,120 @@ internal fun CalendarDayCell(
private fun DayCellInner( private fun DayCellInner(
date: LocalDate, date: LocalDate,
textColor: Color, textColor: Color,
numberStyle: TextStyle,
header: String?,
headerStyle: TextStyle,
headerColor: Color,
indicator: Boolean, indicator: Boolean,
indicatorColor: Color, indicatorColor: Color,
) { ) {
Column( if (header == null) {
modifier = Modifier.fillMaxWidth(), CenteredDayNumber(
horizontalAlignment = Alignment.CenterHorizontally, date = date,
verticalArrangement = Arrangement.Center, textColor = textColor,
) { numberStyle = numberStyle,
indicator = indicator,
indicatorColor = indicatorColor,
)
} else {
HeaderDayNumber(
date = date,
textColor = textColor,
numberStyle = numberStyle,
header = header,
headerStyle = headerStyle,
headerColor = headerColor,
indicator = indicator,
indicatorColor = indicatorColor,
)
}
}
@Composable
private fun CenteredDayNumber(
date: LocalDate,
textColor: Color,
numberStyle: TextStyle,
indicator: Boolean,
indicatorColor: Color,
) {
Box(modifier = Modifier.fillMaxSize()) {
BasicText( BasicText(
text = date.dayOfMonth.toString(), text = date.dayOfMonth.toString(),
style = style = numberStyle.copy(color = textColor),
RecipeTheme.typography.label.copy( modifier = Modifier.align(Alignment.Center),
color = textColor,
fontWeight = FontWeight.SemiBold,
),
) )
if (indicator) { if (indicator) {
Spacer(modifier = Modifier.height(2.dp)) IndicatorDot(
Box( color = indicatorColor,
modifier = modifier = Modifier.align(Alignment.Center).offset(y = 11.dp),
Modifier
.size(4.dp)
.clip(CircleShape)
.background(indicatorColor),
) )
} }
} }
} }
@Composable
private fun HeaderDayNumber(
date: LocalDate,
textColor: Color,
numberStyle: TextStyle,
header: String,
headerStyle: TextStyle,
headerColor: Color,
indicator: Boolean,
indicatorColor: Color,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(text = header, style = headerStyle.copy(color = headerColor))
Spacer(modifier = Modifier.height(1.dp))
BasicText(
text = date.dayOfMonth.toString(),
style = numberStyle.copy(color = textColor),
)
}
if (indicator) {
IndicatorDot(
color = indicatorColor,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
)
}
}
}
@Composable
private fun IndicatorDot(
color: Color,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.size(4.dp)
.clip(CircleShape)
.background(color),
)
}
private fun Modifier.dayCellSurface(
backgroundColor: Color,
ringColor: Color,
): Modifier =
this
.background(backgroundColor, CircleShape)
.then(
if (ringColor == Color.Transparent) {
Modifier
} else {
Modifier.border(1.dp, ringColor, CircleShape)
},
)
private const val INDICATOR_MUTED_ALPHA = 0.6f

View File

@@ -0,0 +1,22 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Composable
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.calendar_horizon_days
import recipe.composeapp.generated.resources.calendar_horizon_today
@Composable
fun horizonLabel(
today: LocalDate,
end: LocalDate,
): String {
val days = (today.daysUntil(end) + 1).coerceAtLeast(1)
return if (days == 1) {
stringResource(Res.string.calendar_horizon_today)
} else {
stringResource(Res.string.calendar_horizon_days, days)
}
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -37,6 +38,7 @@ internal fun WeekdayHeader(
style = style =
RecipeTheme.typography.label.copy( RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted, color = RecipeTheme.colors.contentMuted,
fontWeight = FontWeight.Light,
), ),
) )
} }

View File

@@ -0,0 +1,350 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
enum class CalendarPillExpandDirection {
/** Pill anchored at the bottom; calendar slides into view from above (planner pattern). */
Up,
/** Pill anchored at the top; calendar grows downward beneath it (in-sheet editor pattern). */
Down,
;
/** Sign convention: positive drag/velocity along this axis opens the pill. */
val openingSign: Float
get() =
when (this) {
Up -> -1f
Down -> 1f
}
}
@Composable
fun CalendarPill(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
selectedDate: LocalDate,
today: LocalDate,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
label: String = "",
collapsedContent: (@Composable RowScope.() -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
dayState: (LocalDate) -> DayState = { DayState() },
pillHeight: Dp = 48.dp,
locale: CalendarLocale = CalendarLocale.PL,
expandDirection: CalendarPillExpandDirection = CalendarPillExpandDirection.Up,
tint: Color = RecipeTheme.colors.surfaceGlass,
glass: Boolean = true,
) {
val scope = rememberCoroutineScope()
val expansion = remember { PillExpansion(initial = if (expanded) 1f else 0f) }
LaunchedEffect(expanded) {
expansion.animateTo(scope, target = if (expanded) 1f else 0f)
}
val progress = expansion.progress
val cornerRadius = pillHeight / 2 * (1f - progress) + EXPANDED_CORNER_RADIUS * progress
val pillInset = RecipeTheme.spacing.lg + RecipeTheme.spacing.xs
val pillHeightPx = with(LocalDensity.current) { pillHeight.toPx() }
val dragState =
rememberDraggableState { delta ->
expansion.dragBy(
delta = delta,
range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f),
direction = expandDirection,
)
}
PillSurface(
glass = glass,
tint = tint,
cornerRadius = cornerRadius,
glassStyle = if (expanded) RecipeTheme.glass.panel else RecipeTheme.glass.dock,
modifier =
modifier.draggable(
state = dragState,
orientation = Orientation.Vertical,
onDragStarted = { expansion.cancelSettle() },
onDragStopped = { velocity ->
val openTarget = releaseTarget(expansion.progress, velocity, expandDirection)
val range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f)
val initialVelocity = expandDirection.openingSign * velocity / range
expansion.animateTo(scope, if (openTarget) 1f else 0f, initialVelocity = initialVelocity)
if (openTarget != expanded) onExpandedChange(openTarget)
},
),
) {
Box(modifier = Modifier.fillMaxWidth()) {
CompositionLocalProvider(LocalCalendarInteractive provides expanded) {
Box(
modifier =
Modifier
.fillMaxWidth()
.expandingHeight(progress, pillHeight, expansion, expandDirection)
.alpha(progress),
) {
SwipeableCalendar(
selectedDate = selectedDate,
today = today,
mode = CalendarMode.Month,
onSelectDate = onSelectDate,
onModeChange = {},
onVisibleAnchorChange = {},
dayState = dayState,
expandable = false,
locale = locale,
modifier = Modifier.fillMaxWidth().padding(vertical = RecipeTheme.spacing.lg),
)
}
}
val rowAlpha = (1f - progress / PILL_CONTENT_FADE_END).coerceIn(0f, 1f)
if (rowAlpha > 0f) {
val pillRowAlignment =
when (expandDirection) {
CalendarPillExpandDirection.Up -> Alignment.BottomCenter
CalendarPillExpandDirection.Down -> Alignment.TopCenter
}
Box(
modifier =
Modifier
.fillMaxWidth()
.align(pillRowAlignment)
.alpha(rowAlpha),
) {
PillRow(
label = label,
collapsedContent = collapsedContent,
trailing = trailing,
height = pillHeight,
horizontalInset = pillInset,
)
}
}
}
}
}
/**
* Surface wrapper for the pill. Glass mode is the default and matches the
* planner pattern where the pill sits over a varied app-shell backdrop and
* refraction earns its keep. The flat mode is for in-sheet contexts where the
* backdrop is mostly a solid colour — refraction has nothing meaningful to
* refract and only adds visual noise.
*/
@Composable
private fun PillSurface(
glass: Boolean,
tint: Color,
cornerRadius: Dp,
glassStyle: RecipeGlassStyle,
modifier: Modifier,
content: @Composable BoxScope.() -> Unit,
) {
if (glass) {
GlassSurface(
modifier = modifier,
cornerRadius = cornerRadius,
glassStyle = glassStyle,
content = content,
)
} else {
val colors = RecipeTheme.colors
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.background(tint)
.border(width = FlatBorderWidth, color = colors.borderCard, shape = shape),
content = content,
)
}
}
@Composable
private fun PillRow(
label: String,
collapsedContent: (@Composable RowScope.() -> Unit)?,
trailing: (@Composable () -> Unit)?,
height: Dp,
horizontalInset: Dp,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = horizontalInset),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
if (collapsedContent != null) {
collapsedContent()
} else {
BasicText(
text = label,
style = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
trailing?.invoke()
}
}
}
/**
* Measures the calendar at its full intrinsic height, reports it to [expansion]
* so drag knows the range, then lays out at the lerped height. The placement
* anchor flips with [direction]: anchoring the calendar's bottom edge makes it
* slide in from above (pill at bottom); anchoring the top edge makes the
* calendar reveal downward (pill at top).
*/
private fun Modifier.expandingHeight(
progress: Float,
pillHeight: Dp,
expansion: PillExpansion,
direction: CalendarPillExpandDirection,
): Modifier =
this.layout { measurable, constraints ->
val placeable =
measurable.measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
expansion.reportFullHeight(placeable.height)
val pillHeightPx = pillHeight.roundToPx()
val height = lerp(pillHeightPx, placeable.height, progress).coerceIn(pillHeightPx, placeable.height)
layout(placeable.width, height) {
val placementY =
when (direction) {
CalendarPillExpandDirection.Up -> height - placeable.height
CalendarPillExpandDirection.Down -> 0
}
placeable.place(0, placementY)
}
}
/**
* Single source of truth for pill drag/settle state. Holds [progress] (0 =
* collapsed, 1 = expanded) and tracks [target] so external [expanded] changes
* that match an in-flight settle become no-ops — no flag, no race.
*/
@Stable
private class PillExpansion(
initial: Float,
) {
var progress by mutableFloatStateOf(initial)
private set
var fullHeightPx by mutableIntStateOf(0)
private set
private var target: Float = initial
private var settleJob: Job? = null
fun dragBy(
delta: Float,
range: Float,
direction: CalendarPillExpandDirection,
) {
settleJob?.cancel()
progress = (progress + direction.openingSign * delta / range).coerceIn(0f, 1f)
target = progress
}
fun animateTo(
scope: CoroutineScope,
target: Float,
initialVelocity: Float = 0f,
) {
if (this.target == target && settleJob?.isActive == true) return
this.target = target
settleJob?.cancel()
settleJob =
scope.launch {
Animatable(progress)
.also { it.updateBounds(0f, 1f) }
.animateTo(
targetValue = target,
animationSpec =
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
initialVelocity = initialVelocity,
) { progress = value }
}
}
fun cancelSettle() {
settleJob?.cancel()
}
fun reportFullHeight(height: Int) {
if (fullHeightPx != height) fullHeightPx = height
}
}
private fun releaseTarget(
progress: Float,
velocity: Float,
direction: CalendarPillExpandDirection,
): Boolean {
val openingVelocity = direction.openingSign * velocity
return when {
openingVelocity >= FLING_VELOCITY -> true
openingVelocity <= -FLING_VELOCITY -> false
else -> progress >= 0.5f
}
}
private const val FLING_VELOCITY = 60f
private const val PILL_CONTENT_FADE_END = 0.35f
private val EXPANDED_CORNER_RADIUS = 28.dp
private val FlatBorderWidth = 1.dp

View File

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe.ui.components.calendar package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
/** /**
* Whether the calendar shows a single week strip or the full month grid. * Whether the calendar shows a single week strip or the full month grid.
@@ -8,6 +9,13 @@ import androidx.compose.runtime.Immutable
*/ */
enum class CalendarMode { Week, Month } enum class CalendarMode { Week, Month }
/**
* Day-cell interactivity gate. CalendarPill flips this to `false` while
* collapsed so the always-composed month grid (kept in the tree to feed drag
* its full height) doesn't catch taps that visually belong to the pill row.
*/
internal val LocalCalendarInteractive = staticCompositionLocalOf { true }
/** /**
* Per-day visual modifiers resolved by the caller. Selection and "today" * Per-day visual modifiers resolved by the caller. Selection and "today"
* outline are handled by the surface itself and must not be set here. * outline are handled by the surface itself and must not be set here.

View File

@@ -0,0 +1,51 @@
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
/**
* Mon-anchored 7-day strip rendering [CalendarDayCell] per day. Used by every
* surface that embeds [CalendarPill] in its collapsed form (planner, meal-plan
* editor, future pantry/shopping pills).
*/
@Composable
fun CalendarWeekStrip(
selectedDate: LocalDate,
today: LocalDate,
onSelectDate: (LocalDate) -> Unit,
numberStyle: TextStyle,
modifier: Modifier = Modifier,
dayState: (LocalDate) -> DayState = { DayState() },
locale: CalendarLocale = CalendarLocale.PL,
) {
val days = weekStripDays(selectedDate)
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 val DayCellGap = 4.dp

View File

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

View File

@@ -0,0 +1,48 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
@Immutable
data class HorizonCalendarState(
val selectedDate: LocalDate,
val isCalendarOpen: Boolean = false,
)
/**
* Shared state holder for "pick a horizon date" screens (Pantry, Shopping).
* Owns the date + open flag and enforces "no past dates" on selection. Lives
* inside the owning ViewModel as a plain field — not a ViewModel itself.
*
* [today] is parameterised so tests can pin the clock.
*/
class HorizonCalendarHolder(
initialDate: LocalDate = defaultHorizon(),
private val today: () -> LocalDate = ::todayInSystemTz,
) {
private val _state = MutableStateFlow(HorizonCalendarState(selectedDate = initialDate))
val state: StateFlow<HorizonCalendarState> = _state.asStateFlow()
fun setOpen(open: Boolean) {
_state.update { it.copy(isCalendarOpen = open) }
}
fun close() = setOpen(false)
fun select(date: LocalDate) {
if (date < today()) return
_state.update { it.copy(selectedDate = date, isCalendarOpen = false) }
}
companion object {
private const val DEFAULT_HORIZON_DAYS = 7
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate = today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
}
}

View File

@@ -0,0 +1,31 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.datetime.LocalDate
@Composable
fun HorizonCalendarPill(
selectedDate: LocalDate,
expanded: Boolean,
today: LocalDate,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
trailing: @Composable () -> Unit,
) {
CalendarPill(
label = horizonLabel(today, selectedDate),
expanded = expanded,
onExpandedChange = onExpandedChange,
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate,
trailing = trailing,
dayState = { date ->
if (date < today) DayState(disabled = true, dimmed = true) else DayState()
},
modifier = modifier.fillMaxWidth(),
)
}

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

@@ -0,0 +1,86 @@
package dev.ulfrx.recipe.ui.components.overlay
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
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
/**
* Scaffold for a bottom-anchored modal overlay (calendar pill today; future
* bottom-sheets, filter panels). Owns four crosscuts so screens don't repeat
* them:
* - **Local glass backdrop** — Liquid refraction filters the nearest
* liquefiable ancestor, so the overlay must be a sibling of its own
* backdrop source (not a descendant of the shell's global one).
* - **Scrim** — tap-outside dismisses while [open] is true.
* - **Tab/route exit** — closes the overlay on dispose to keep state honest
* when the user navigates away mid-open.
* - **Active-tab tap** — registers with [OverlayDismisser] so a tap on the
* already-active tab in the shell closes us too.
*/
@Composable
fun BottomOverlayScaffold(
open: Boolean,
onDismiss: () -> Unit,
bottomInset: Dp,
modifier: Modifier = Modifier,
overlay: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
val backdrop = rememberGlassBackdropState()
val latestOnDismiss by rememberUpdatedState(onDismiss)
val latestOpen by rememberUpdatedState(open)
DisposableEffect(Unit) {
onDispose { if (latestOpen) latestOnDismiss() }
}
RegisterDismissibleOverlay(active = open, onDismiss = onDismiss)
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(modifier = modifier.fillMaxSize()) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background)) {
content()
}
}
if (open) {
Box(
modifier =
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { onDismiss() }
},
)
}
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = RecipeTheme.spacing.xl)
.padding(bottom = bottomInset),
) {
overlay()
}
}
}
}

View File

@@ -0,0 +1,40 @@
package dev.ulfrx.recipe.ui.components.overlay
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.staticCompositionLocalOf
@Stable
class OverlayDismisser {
private val handlers = mutableListOf<() -> Unit>()
fun register(onDismiss: () -> Unit): () -> Unit {
handlers += onDismiss
return { handlers -= onDismiss }
}
fun dismissAll() {
handlers.toList().forEach { it() }
}
}
val LocalOverlayDismisser =
staticCompositionLocalOf<OverlayDismisser> {
error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.")
}
@Composable
fun RegisterDismissibleOverlay(
active: Boolean,
onDismiss: () -> Unit,
) {
val dismisser = LocalOverlayDismisser.current
val latestOnDismiss by rememberUpdatedState(onDismiss)
DisposableEffect(dismisser, active) {
val unregister = if (active) dismisser.register { latestOnDismiss() } else null
onDispose { unregister?.invoke() }
}
}

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

@@ -0,0 +1,293 @@
package dev.ulfrx.recipe.ui.components.recipe
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.X
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.ingredient_substitute_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_added_marker_a11y
import recipe.composeapp.generated.resources.meal_plan_editor_remove_ingredient_a11y
data class RecipeIngredientOptionUi(
val id: String,
val name: String,
val amount: Double,
val unit: String,
)
data class RecipeIngredientSlotUi(
val default: RecipeIngredientOptionUi,
val alternatives: List<RecipeIngredientOptionUi> = emptyList(),
val id: String = default.id,
)
/**
* Shared row used in both the read-only recipe detail and the meal-plan
* editor. Detail uses the base form (name + optional swap + amount); editor
* passes [onRemove] / [addedMarker] to surface its extra affordances inside
* the same visual language.
*/
@Composable
fun IngredientRow(
slot: RecipeIngredientSlotUi,
modifier: Modifier = Modifier,
selectedOptionId: String = slot.default.id,
onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null,
addedMarker: Boolean = false,
onRemove: (() -> Unit)? = null,
) {
val options = slot.options
val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default
val swappable = slot.alternatives.isNotEmpty() && onSelect != null
var expanded by remember(slot.id) { mutableStateOf(false) }
Column(
modifier =
modifier
.fillMaxWidth()
.animateContentSize(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = MinRowHeight)
.padding(horizontal = PaddingHorizontal, vertical = PaddingVertical),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
NameLine(
name = selected.name,
addedMarker = addedMarker,
modifier = Modifier.weight(1f),
)
if (swappable) {
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,
)
}
}
if (swappable && expanded) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(start = PaddingHorizontal, end = PaddingHorizontal, bottom = PaddingVertical),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
options.forEach { option ->
AlternativeOption(
option = option,
selected = option.id == selected.id,
onClick = {
onSelect(option)
expanded = false
},
)
}
}
}
}
}
@Composable
private fun NameLine(
name: String,
addedMarker: Boolean,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
BasicText(
text = name,
style =
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = NameTextSize,
lineHeight = LineHeight,
),
)
if (addedMarker) {
UnstyledIcon(
imageVector = Lucide.Plus,
contentDescription = stringResource(Res.string.meal_plan_editor_added_marker_a11y),
tint = colors.contentMuted,
modifier = Modifier.size(AddedMarkerSize),
)
}
}
}
@Composable
private fun IconBadgeButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
UnstyledButton(
onClick = onClick,
modifier = Modifier.size(ToggleSize),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
UnstyledIcon(
imageVector = icon,
contentDescription = contentDescription,
tint = RecipeTheme.colors.contentMuted,
modifier = Modifier.size(ToggleIconSize),
)
}
}
}
@Composable
private fun AlternativeOption(
option: RecipeIngredientOptionUi,
selected: Boolean,
onClick: () -> Unit,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
UnstyledButton(
onClick = onClick,
backgroundColor = colors.background,
contentColor = colors.content,
shape = RoundedCornerShape(OptionCornerRadius),
contentPadding = PaddingValues(OptionPadding),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
Column(modifier = Modifier.weight(1f)) {
BasicText(
text = option.name,
style =
typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Medium,
fontSize = OptionNameTextSize,
lineHeight = OptionNameLineHeight,
),
)
Spacer(Modifier.height(OptionMetaGap))
BasicText(
text = formatIngredientAmount(option.amount) + " " + option.unit,
style =
typography.body.copy(
color = colors.contentMuted,
fontSize = OptionMetaTextSize,
lineHeight = OptionMetaLineHeight,
),
)
}
SelectionMark(selected = selected)
}
}
}
@Composable
private fun SelectionMark(selected: Boolean) {
val colors = RecipeTheme.colors
Box(
modifier =
Modifier
.size(SelectionMarkSize)
.clip(RoundedCornerShape(percent = 50))
.border(
width = SelectionMarkBorder,
color = colors.separator,
shape = RoundedCornerShape(percent = 50),
),
contentAlignment = Alignment.Center,
) {
if (selected) {
UnstyledIcon(
imageVector = Lucide.Check,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(SelectionCheckSize),
)
}
}
}
internal val RecipeIngredientSlotUi.options: List<RecipeIngredientOptionUi>
get() = listOf(default) + alternatives
internal fun RecipeIngredientSlotUi.scaledBy(servings: Int) =
RecipeIngredientSlotUi(
default = default.copy(amount = default.amount * servings),
alternatives = alternatives.map { it.copy(amount = it.amount * servings) },
id = id,
)
private val MinRowHeight = 48.dp
private val PaddingHorizontal = 12.dp
private val PaddingVertical = 12.dp
private val NameTextSize = 12.sp
private val LineHeight = 16.sp
private val ToggleSize = 24.dp
private val ToggleIconSize = 12.dp
private val AddedMarkerSize = 10.dp
private val OptionCornerRadius = 10.dp
private val OptionPadding = 12.dp
private val OptionMetaGap = 2.dp
private val OptionNameTextSize = 11.sp
private val OptionNameLineHeight = 14.sp
private val OptionMetaTextSize = 10.sp
private val OptionMetaLineHeight = 13.sp
private val SelectionMarkSize = 18.dp
private val SelectionMarkBorder = 1.5.dp
private val SelectionCheckSize = 10.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

@@ -0,0 +1,121 @@
package dev.ulfrx.recipe.ui.components.recipe
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.nutrition_grams_format
import recipe.composeapp.generated.resources.nutrition_macro_carbs
import recipe.composeapp.generated.resources.nutrition_macro_fat
import recipe.composeapp.generated.resources.nutrition_macro_kcal
import recipe.composeapp.generated.resources.nutrition_macro_protein
data class RecipeNutritionUi(
val kcal: Int,
val protein: Int,
val fat: Int,
val carbs: Int,
)
internal fun RecipeNutritionUi.scaledBy(servings: Int) =
RecipeNutritionUi(
kcal = kcal * servings,
protein = protein * servings,
fat = fat * servings,
carbs = carbs * servings,
)
@Composable
fun NutritionSummary(
nutrition: RecipeNutritionUi,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
MacroCard(
modifier = Modifier.weight(1f),
value = nutrition.kcal.toString(),
label = stringResource(Res.string.nutrition_macro_kcal),
valueColor = colors.content,
)
MacroCard(
modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.protein),
label = stringResource(Res.string.nutrition_macro_protein),
valueColor = colors.macroProtein,
)
MacroCard(
modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.fat),
label = stringResource(Res.string.nutrition_macro_fat),
valueColor = colors.macroFat,
)
MacroCard(
modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs),
label = stringResource(Res.string.nutrition_macro_carbs),
valueColor = colors.macroCarbs,
)
}
}
@Composable
private fun MacroCard(
value: String,
label: String,
valueColor: Color,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
Column(
modifier =
modifier
.clip(RoundedCornerShape(CardCornerRadius))
.background(colors.surface)
.padding(vertical = RecipeTheme.spacing.sm, horizontal = RecipeTheme.spacing.xs),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(
text = value,
style =
RecipeTheme.typography.body.copy(
color = valueColor,
fontWeight = FontWeight.Bold,
fontSize = ValueTextSize,
),
)
Spacer(Modifier.height(RecipeTheme.spacing.xs))
BasicText(
text = label,
style =
RecipeTheme.typography.label.copy(
color = colors.contentMuted,
fontSize = LabelTextSize,
fontWeight = FontWeight.Normal,
),
)
}
}
private val CardCornerRadius = 12.dp
private val ValueTextSize = 16.sp
private val LabelTextSize = 11.sp

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

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
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.pantry_shortfall_count
@Composable
fun PantryHorizonPill(
selectedDate: LocalDate,
expanded: Boolean,
today: LocalDate,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
HorizonCalendarPill(
selectedDate = selectedDate,
expanded = expanded,
today = today,
onExpandedChange = onExpandedChange,
onSelectDate = onSelectDate,
trailing = {
BasicText(
text = stringResource(Res.string.pantry_shortfall_count, DUMMY_SHORTFALLS),
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.destructive),
maxLines = 1,
)
},
modifier = modifier,
)
}
private const val DUMMY_SHORTFALLS = 7

View File

@@ -1,6 +1,5 @@
package dev.ulfrx.recipe.ui.screens.pantry package dev.ulfrx.recipe.ui.screens.pantry
import androidx.compose.foundation.background
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.Column import androidx.compose.foundation.layout.Column
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
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
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_pantry_subtitle
import recipe.composeapp.generated.resources.empty_pantry_title import recipe.composeapp.generated.resources.empty_pantry_title
import recipe.composeapp.generated.resources.shell_tab_pantry import recipe.composeapp.generated.resources.shell_tab_pantry
/**
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
* empty body with the inventory list.
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable @Composable
fun PantryScreen(viewModel: PantryViewModel) { fun PantryScreen(viewModel: PantryViewModel) {
@Suppress("UNUSED_VARIABLE") val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle() val today = remember { todayInSystemTz() }
Box( BottomOverlayScaffold(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), open = horizonState.isCalendarOpen,
onDismiss = viewModel.horizon::close,
bottomInset = rememberShellChromeHeight(),
overlay = {
PantryHorizonPill(
selectedDate = horizonState.selectedDate,
expanded = horizonState.isCalendarOpen,
today = today,
onExpandedChange = viewModel.horizon::setOpen,
onSelectDate = viewModel.horizon::select,
)
},
) { ) {
Column( Column(
modifier = modifier =

View File

@@ -1,19 +1,8 @@
package dev.ulfrx.recipe.ui.screens.pantry package dev.ulfrx.recipe.ui.screens.pantry
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [PantryScreen]. Phase 2.1 ships only the empty state. Phase 8
* (Pantry) extends this with inventory rows + actions.
*/
data class PantryState(
val isEmpty: Boolean = true,
)
class PantryViewModel : ViewModel() { class PantryViewModel : ViewModel() {
private val _state = MutableStateFlow(PantryState()) val horizon = HorizonCalendarHolder()
val state: StateFlow<PantryState> = _state.asStateFlow()
} }

View File

@@ -0,0 +1,41 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import dev.ulfrx.recipe.ui.components.calendar.RecipeCalendarPill
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
/**
* Planner-screen flavour of [RecipeCalendarPill] — supplies the dummy
* "you already have something planned" indicators that will be replaced by
* real planner data in Phase 6.
*/
@Composable
fun PlannerCalendarPill(
selectedDate: LocalDate,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
onShiftSelection: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val plannedDummy =
remember {
val today = todayInSystemTz()
setOf(today, today.plus(DatePeriod(days = 1)), today.plus(DatePeriod(days = 3)))
}
RecipeCalendarPill(
selectedDate = selectedDate,
expanded = expanded,
onExpandedChange = onExpandedChange,
onSelectDate = onSelectDate,
onSelectionShift = onShiftSelection,
plannedDates = plannedDummy,
modifier = modifier,
)
}

View File

@@ -1,81 +1,67 @@
package dev.ulfrx.recipe.ui.screens.planner package dev.ulfrx.recipe.ui.screens.planner
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.ui.components.calendar.SwipeableCalendar import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
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.empty_planner_subtitle
import recipe.composeapp.generated.resources.empty_planner_title
import recipe.composeapp.generated.resources.shell_tab_planner import recipe.composeapp.generated.resources.shell_tab_planner
/**
* Phase 2.1 — planner shell with the shared calendar at the top. Phase 6 fills
* in the area below the calendar with meal slots driven by [PlannerState.selectedDate].
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable @Composable
fun PlannerScreen(viewModel: PlannerViewModel) { fun PlannerScreen(viewModel: PlannerViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val today = remember { todayInSystemTz() }
Box( BottomOverlayScaffold(
modifier = open = state.isCalendarOpen,
Modifier onDismiss = viewModel::closeCalendar,
.fillMaxSize() bottomInset = rememberShellChromeHeight(),
.background(RecipeTheme.colors.background), overlay = {
PlannerCalendarPill(
selectedDate = state.selectedDate,
expanded = state.isCalendarOpen,
onExpandedChange = viewModel::setCalendarOpen,
onSelectDate = viewModel::selectDate,
onShiftSelection = viewModel::shiftSelection,
)
},
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars), .windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) { ) {
BasicText( BasicText(
text = stringResource(Res.string.shell_tab_planner), text = stringResource(Res.string.shell_tab_planner),
style = style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
RecipeTheme.typography.title.copy( modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
color = RecipeTheme.colors.content,
),
modifier =
Modifier.padding(
top = RecipeTheme.spacing.xl,
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
),
) )
Box(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(RecipeTheme.spacing.lg)) EmptyState(
icon = DockDestination.Planner.icon,
SwipeableCalendar( title = stringResource(Res.string.empty_planner_title),
selectedDate = state.selectedDate, subtitle = stringResource(Res.string.empty_planner_subtitle),
today = today,
mode = state.calendarMode,
onSelectDate = viewModel::selectDate,
onModeChange = viewModel::setCalendarMode,
// Swipe auto-follows: dropping into a new week/month bumps
// the selection by the same offset (kotlinx.datetime clamps
// day-of-month for short months).
onVisibleAnchorChange = viewModel::selectDate,
expandable = true,
modifier = Modifier.fillMaxWidth(),
) )
} }
} }
}
} }

View File

@@ -1,7 +1,6 @@
package dev.ulfrx.recipe.ui.screens.planner package dev.ulfrx.recipe.ui.screens.planner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.calendar.CalendarMode
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -9,31 +8,33 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
/**
* UI state for [PlannerScreen]. Phase 2.1 ships only the calendar; Phase 6
* extends this with day-plan data, meal slot actions, and pantry/shortfall
* derivations driven by [selectedDate].
*/
data class PlannerState( data class PlannerState(
val selectedDate: LocalDate, val selectedDate: LocalDate,
val calendarMode: CalendarMode, val isCalendarOpen: Boolean = false,
) )
class PlannerViewModel : ViewModel() { class PlannerViewModel : ViewModel() {
private val _state = private val _state = MutableStateFlow(PlannerState(selectedDate = todayInSystemTz()))
MutableStateFlow(
PlannerState(
selectedDate = todayInSystemTz(),
calendarMode = CalendarMode.Week,
),
)
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) } _state.update { it.copy(selectedDate = date) }
} }
fun setCalendarMode(mode: CalendarMode) { /**
_state.update { it.copy(calendarMode = mode) } * 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) {
_state.update { it.copy(isCalendarOpen = open) }
}
fun closeCalendar() {
_state.update { it.copy(isCalendarOpen = false) }
} }
} }

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

@@ -0,0 +1,16 @@
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 MAX_RECIPE_SERVINGS = 12
sealed interface RecipeDetailState {
data object NotFound : RecipeDetailState
data class Ready(
val recipe: RecipeUi,
val servings: Int = MIN_RECIPE_SERVINGS,
val substitutions: Map<String, String> = emptyMap(),
) : RecipeDetailState
}

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.lifecycle.ViewModel
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
class RecipeDetailViewModel(recipeId: String) : ViewModel() {
private val _state = MutableStateFlow(sampleRecipe(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.NotFound)
val state: StateFlow<RecipeDetailState> = _state.asStateFlow()
fun setServings(value: Int) =
_state.update { current ->
if (current is RecipeDetailState.Ready) {
current.copy(servings = value.coerceIn(MIN_RECIPE_SERVINGS, MAX_RECIPE_SERVINGS))
} else {
current
}
}
fun selectSubstitution(
slotId: String,
optionId: String,
) = _state.update { current ->
if (current !is RecipeDetailState.Ready) return@update current
val slot = current.recipe.ingredients.firstOrNull { it.id == slotId } ?: return@update current
if (slot.options.none { it.id == optionId }) return@update current
val substitutions =
if (optionId == slot.default.id) {
current.substitutions - slotId
} else {
current.substitutions + (slotId to optionId)
}
current.copy(substitutions = substitutions)
}
}

View File

@@ -0,0 +1,421 @@
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.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
private val LunchOrDinner = listOf(MealSlot.Lunch, MealSlot.Dinner)
private val BreakfastOrSnack = listOf(MealSlot.Breakfast, MealSlot.Snack)
private val LightMeal = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper)
internal val sampleRecipes: Map<String, RecipeUi> =
listOf(
RecipeUi(
id = "rcp_nalesniki",
title = "Naleśniki z twarogiem",
cookingMinutes = 25,
nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
ingredients =
listOf(
slot("Mąka pszenna", 60.0, "g"),
slot("Mleko", 125.0, "ml"),
slot("Jajka", 1.0, "szt."),
slot(
"Twaróg półtłusty",
100.0,
"g",
alt("Twaróg chudy", 100.0, "g"),
alt("Serek wiejski", 120.0, "g"),
),
slot("Miód", 10.0, "g", alt("Syrop klonowy", 12.0, "g")),
slot("Olej do smażenia", 5.0, "ml"),
),
steps =
listOf(
"Zmiksuj mąkę, mleko, jajko i szczyptę soli na gładkie ciasto. Odstaw na 10 minut.",
"Rozgrzej odrobinę oleju na patelni i smaż cienkie naleśniki z obu stron na złoto.",
"Twaróg rozetrzyj z miodem na gładką masę.",
"Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
),
),
RecipeUi(
id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami",
cookingMinutes = 10,
nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38),
allowedSlots = BreakfastOrSnack,
ingredients =
listOf(
slot("Płatki owsiane", 50.0, "g"),
slot(
"Mleko",
200.0,
"ml",
alt("Napój owsiany", 200.0, "ml"),
alt("Napój migdałowy", 200.0, "ml"),
),
slot("Banan", 0.5, "szt."),
slot(
"Borówki",
40.0,
"g",
alt("Maliny", 40.0, "g"),
alt("Truskawki", 50.0, "g"),
),
slot(
"Orzechy włoskie",
15.0,
"g",
alt("Migdały", 15.0, "g"),
alt("Orzechy laskowe", 15.0, "g"),
),
slot("Miód", 10.0, "g"),
),
steps =
listOf(
"Płatki owsiane zalej mlekiem i gotuj na małym ogniu 45 minut, mieszając.",
"Przełóż owsiankę do miski.",
"Ułóż na wierzchu pokrojonego banana, borówki i posiekane orzechy.",
"Polej miodem i podawaj.",
),
),
RecipeUi(
id = "rcp_spaghetti",
title = "Spaghetti bolognese",
cookingMinutes = 40,
nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Makaron spaghetti", 100.0, "g"),
slot(
"Mięso mielone wołowe",
120.0,
"g",
alt("Mięso mielone z indyka", 120.0, "g"),
alt("Soczewica czerwona", 60.0, "g"),
),
slot("Passata pomidorowa", 150.0, "ml"),
slot("Cebula", 0.5, "szt."),
slot("Czosnek", 1.0, "ząbek"),
slot("Oliwa", 10.0, "ml"),
),
steps =
listOf(
"Makaron ugotuj al dente w osolonej wodzie wg opakowania.",
"Na oliwie zeszklij posiekaną cebulę i czosnek, dodaj mięso i smaż do zrumienienia.",
"Wlej passatę, dopraw solą, pieprzem i ziołami. Duś 15 minut.",
"Wymieszaj sos z odsączonym makaronem i podawaj.",
),
),
RecipeUi(
id = "rcp_pierogi",
title = "Pierogi ruskie",
cookingMinutes = 90,
nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Mąka pszenna", 120.0, "g"),
slot("Woda", 60.0, "ml"),
slot("Ziemniaki", 150.0, "g"),
slot("Twaróg półtłusty", 80.0, "g"),
slot("Cebula", 0.5, "szt."),
slot("Masło", 10.0, "g"),
),
steps =
listOf(
"Z mąki, ciepłej wody i szczypty soli zagnieć gładkie ciasto. Odstaw pod ściereczką.",
"Ziemniaki ugotuj i ugnieć z twarogiem. Dodaj zeszkloną cebulę, dopraw solą i pieprzem.",
"Rozwałkuj ciasto, wykrawaj krążki, nakładaj farsz i zlepiaj pierogi.",
"Gotuj partiami w osolonej wodzie 34 minuty od wypłynięcia.",
"Podawaj okraszone masłem i podsmażoną cebulą.",
),
),
RecipeUi(
id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem",
cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Lunch, MealSlot.Supper, MealSlot.Snack),
ingredients =
listOf(
slot("Pieczywo razowe", 1.0, "kromka"),
slot("Awokado", 0.5, "szt."),
slot("Jajko", 1.0, "szt."),
slot("Sok z cytryny", 5.0, "ml"),
slot("Szczypiorek", 5.0, "g"),
),
steps =
listOf(
"Jajko ugotuj na twardo (ok. 9 minut), ostudź i obierz.",
"Awokado rozgnieć widelcem z sokiem z cytryny, solą i pieprzem.",
"Posmaruj kromkę pastą z awokado.",
"Ułóż plastry jajka i posyp szczypiorkiem.",
),
),
RecipeUi(
id = "rcp_schabowy",
title = "Schabowy z ziemniakami",
cookingMinutes = 60,
nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Schab", 150.0, "g"),
slot("Jajko", 1.0, "szt."),
slot("Bułka tarta", 40.0, "g"),
slot("Mąka pszenna", 20.0, "g"),
slot("Ziemniaki", 300.0, "g"),
slot("Olej do smażenia", 30.0, "ml"),
),
steps =
listOf(
"Ziemniaki obierz i ugotuj w osolonej wodzie.",
"Schab rozbij na cienkie kotlety, dopraw solą i pieprzem.",
"Panieruj kolejno w mące, rozkłóconym jajku i bułce tartej.",
"Smaż na rozgrzanym oleju z obu stron na złoto.",
"Podawaj z ziemniakami i ulubioną surówką.",
),
),
RecipeUi(
id = "rcp_salatka_grecka",
title = "Sałatka grecka",
cookingMinutes = 15,
nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12),
allowedSlots = LightMeal,
ingredients =
listOf(
slot("Pomidory", 150.0, "g"),
slot("Ogórek", 0.5, "szt."),
slot("Papryka czerwona", 0.5, "szt."),
slot("Ser feta", 60.0, "g", alt("Ser sałatkowy", 60.0, "g")),
slot("Oliwki czarne", 30.0, "g"),
slot("Oliwa", 15.0, "ml"),
),
steps =
listOf(
"Pomidory, ogórka i paprykę pokrój w grubą kostkę.",
"Przełóż warzywa do miski, dodaj oliwki.",
"Pokrusz fetę na wierzch.",
"Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
),
),
RecipeUi(
id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem",
cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Passata pomidorowa", 200.0, "ml"),
slot("Bulion warzywny", 400.0, "ml"),
slot("Ryż", 40.0, "g"),
slot("Marchewka", 1.0, "szt."),
slot("Śmietana 18%", 20.0, "ml"),
),
steps =
listOf(
"Ryż ugotuj osobno do miękkości.",
"W garnku zagotuj bulion ze startą marchewką, gotuj 10 minut.",
"Wlej passatę i gotuj kolejne 10 minut. Dopraw solą i cukrem.",
"Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
),
),
RecipeUi(
id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati",
cookingMinutes = 45,
nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Pierś z kurczaka", 150.0, "g"),
slot("Ryż basmati", 80.0, "g"),
slot("Mleko kokosowe", 120.0, "ml", alt("Śmietanka 18%", 120.0, "ml")),
slot("Pasta curry", 20.0, "g"),
slot("Cebula", 0.5, "szt."),
slot("Olej", 10.0, "ml"),
),
steps =
listOf(
"Ryż basmati ugotuj wg opakowania.",
"Kurczaka pokrój w kostkę i obsmaż na oleju z posiekaną cebulą.",
"Dodaj pastę curry, smaż minutę, wlej mleko kokosowe.",
"Duś 1215 minut do zgęstnienia sosu. Dopraw solą.",
"Podawaj z ryżem basmati.",
),
),
RecipeUi(
id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem",
cookingMinutes = 8,
nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3),
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
ingredients =
listOf(
slot("Jajka", 3.0, "szt."),
slot("Masło", 10.0, "g"),
slot("Szczypiorek", 10.0, "g"),
),
steps =
listOf(
"Rozpuść masło na patelni na małym ogniu.",
"Wbij jajka i smaż, delikatnie mieszając, do ścięcia.",
"Dopraw solą i pieprzem.",
"Posyp posiekanym szczypiorkiem i podawaj.",
),
),
RecipeUi(
id = "rcp_risotto",
title = "Risotto z grzybami leśnymi",
cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Ryż arborio", 80.0, "g"),
slot("Grzyby leśne", 100.0, "g"),
slot("Bulion warzywny", 350.0, "ml"),
slot("Cebula", 0.5, "szt."),
slot("Parmezan", 20.0, "g"),
slot("Masło", 15.0, "g"),
),
steps =
listOf(
"Na maśle zeszklij posiekaną cebulę, dodaj grzyby i podsmaż.",
"Wsyp ryż i smaż minutę, aż stanie się szklisty.",
"Dolewaj ciepły bulion po chochli, mieszając, aż ryż go wchłonie.",
"Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.",
),
),
RecipeUi(
id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami",
cookingMinutes = 20,
nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48),
allowedSlots = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper),
ingredients =
listOf(
slot("Tortilla pszenna", 1.0, "szt."),
slot("Pierś z kurczaka", 120.0, "g"),
slot("Papryka", 0.5, "szt."),
slot("Sałata", 30.0, "g"),
slot("Sos jogurtowy", 30.0, "g"),
slot("Olej", 5.0, "ml"),
),
steps =
listOf(
"Kurczaka pokrój w paski, dopraw i obsmaż na oleju.",
"Paprykę pokrój w cienkie paski.",
"Tortillę podgrzej na suchej patelni.",
"Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
),
),
RecipeUi(
id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe",
cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33),
allowedSlots = BreakfastOrSnack,
ingredients =
listOf(
slot("Banan", 1.0, "szt."),
slot("Szpinak świeży", 30.0, "g"),
slot(
"Jogurt naturalny",
100.0,
"g",
alt("Skyr", 100.0, "g"),
alt("Kefir", 120.0, "g"),
),
slot("Mleko", 100.0, "ml", alt("Napój owsiany", 100.0, "ml")),
),
steps =
listOf(
"Wszystkie składniki umieść w blenderze.",
"Miksuj do uzyskania gładkiej konsystencji.",
"Przelej do szklanki i podawaj od razu.",
),
),
RecipeUi(
id = "rcp_losos",
title = "Łosoś pieczony z brokułami",
cookingMinutes = 30,
nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Filet z łososia", 150.0, "g"),
slot("Brokuł", 200.0, "g"),
slot("Oliwa", 15.0, "ml"),
slot("Cytryna", 0.5, "szt."),
slot("Czosnek", 1.0, "ząbek"),
),
steps =
listOf(
"Piekarnik nagrzej do 200°C.",
"Łososia skrop oliwą i sokiem z cytryny, dopraw solą i pieprzem.",
"Brokuł podziel na różyczki, wymieszaj z oliwą i czosnkiem.",
"Piecz łososia i brokuły na blasze ok. 1518 minut.",
),
),
RecipeUi(
id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami",
cookingMinutes = 55,
nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58),
allowedSlots = LunchOrDinner,
ingredients =
listOf(
slot("Papryka", 2.0, "szt."),
slot("Kasza jaglana", 60.0, "g"),
slot("Cukinia", 80.0, "g"),
slot("Passata pomidorowa", 100.0, "ml"),
slot("Cebula", 0.5, "szt."),
slot("Oliwa", 10.0, "ml"),
),
steps =
listOf(
"Kaszę jaglaną ugotuj do miękkości.",
"Na oliwie podsmaż cebulę i pokrojoną cukinię.",
"Wymieszaj kaszę z warzywami i połową passaty. Dopraw.",
"Papryki przekrój, oczyść i napełnij farszem.",
"Polej resztą passaty i piecz w 190°C ok. 30 minut.",
),
),
).associateBy { it.id }
internal fun sampleRecipe(id: String): RecipeUi? = sampleRecipes[id]
private fun slot(
name: String,
amount: Double,
unit: String,
vararg alternatives: RecipeIngredientOptionUi,
) = RecipeIngredientSlotUi(
default = option(name, amount, unit),
alternatives = alternatives.toList(),
id = "sample-slot:$name:$amount:$unit",
)
private fun option(
name: String,
amount: Double,
unit: String,
) = RecipeIngredientOptionUi(
id = "sample:$name",
name = name,
amount = amount,
unit = unit,
)
private fun alt(
name: String,
amount: Double,
unit: String,
) = option(name, amount, unit)

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

@@ -7,39 +7,35 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier 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.components.sheet.rememberRecipeBottomSheetState
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.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_curated_subtitle
import recipe.composeapp.generated.resources.search_screen_curated_title
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
/**
* Global search destination — overlays the active tab when
* [ShellSearchViewModel.state.isOpen] is true. Hosted by `AppShell`, not by the
* tab `NavDisplay`, so navigating in/out doesn't disturb per-tab back stacks.
*
* Two body modes driven by `state.isFocused`:
* - **B (unfocused)** — curated landing. v1 placeholder copy; later phases will
* surface recents, quick filters, and per-tab shortcuts here.
* - **C (focused)** — live search. v1 shows an empty-results hint until per-
* feature SearchSources are wired in Phase 5/6/8/9.
*
* The search input pill itself lives in the bottom chrome (see `SearchChrome.kt`),
* not on this screen — keeping the keyboard-adjacent affordance consistent with
* the rest of the shell.
*/
@Composable @Composable
fun SearchScreen(viewModel: ShellSearchViewModel) { fun SearchScreen(
viewModel: ShellSearchViewModel,
catalogGridState: LazyGridState,
) {
val catalogViewModel: RecipeCatalogViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
val bottomSheetState = rememberRecipeBottomSheetState<Screen>()
Box( Box(
modifier = modifier =
@@ -47,6 +43,7 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
.fillMaxSize() .fillMaxSize()
.background(RecipeTheme.colors.background), .background(RecipeTheme.colors.background),
) { ) {
if (state.isFocused) {
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -54,19 +51,21 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
.windowInsetsPadding(WindowInsets.statusBars) .windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl), .padding(top = RecipeTheme.spacing.xl),
) { ) {
if (state.isFocused) {
EmptyState( EmptyState(
icon = Lucide.Search, icon = Lucide.Search,
title = stringResource(Res.string.search_screen_empty_results_title), title = stringResource(Res.string.search_screen_empty_results_title),
subtitle = stringResource(Res.string.search_screen_empty_results_subtitle), subtitle = stringResource(Res.string.search_screen_empty_results_subtitle),
) )
}
} else { } else {
EmptyState( RecipeCatalogGrid(
icon = Lucide.Search, state = catalogState,
title = stringResource(Res.string.search_screen_curated_title), onRecipeClick = { bottomSheetState.open(Screen.RecipeDetail(it)) },
subtitle = stringResource(Res.string.search_screen_curated_subtitle), gridState = catalogGridState,
modifier = Modifier.fillMaxSize(),
) )
} }
}
RecipeSheet(state = bottomSheetState)
} }
} }

View File

@@ -0,0 +1,8 @@
package dev.ulfrx.recipe.ui.screens.search.catalog
data class RecipeCardUi(
val id: String,
val title: String,
val cookingMinutes: Int,
val kcal: Int,
)

View File

@@ -0,0 +1,219 @@
package dev.ulfrx.recipe.ui.screens.search.catalog
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Flame
import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.recipe_card_kcal_format
import recipe.composeapp.generated.resources.recipe_card_minutes_format
import recipe.composeapp.generated.resources.sample_recipe
@Composable
fun RecipeCatalogGrid(
state: RecipeCatalogState,
onRecipeClick: (String) -> Unit,
modifier: Modifier = Modifier,
gridState: LazyGridState = rememberLazyGridState(),
) {
val spacing = RecipeTheme.spacing
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = MinRecipeCardWidth),
state = gridState,
modifier = modifier.fillMaxSize(),
contentPadding =
PaddingValues(
start = spacing.lg,
end = spacing.lg,
top = statusBarTop + spacing.lg,
bottom = GridBottomPadding,
),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(state.cards, key = { it.id }) { card ->
RecipeCard(card = card, onClick = { onRecipeClick(card.id) })
}
}
}
@Composable
private fun RecipeCard(
card: RecipeCardUi,
onClick: () -> Unit,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val cardShape = RoundedCornerShape(CardCornerRadius)
UnstyledButton(
onClick = onClick,
backgroundColor = colors.surface,
contentColor = colors.content,
shape = cardShape,
borderColor = Color.Transparent,
borderWidth = 0.dp,
modifier =
Modifier
.fillMaxWidth()
.height(CardHeight)
.shadow(elevation = CardElevation, shape = cardShape, clip = false)
.clip(cardShape),
) {
Column(modifier = Modifier.fillMaxSize()) {
RecipeThumbnail()
Column(
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(CardContentPadding),
) {
BasicText(
text = card.title,
style =
typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
fontSize = CardTitleTextSize,
lineHeight = CardTitleLineHeight,
),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.weight(1f))
RecipeMetaRow(card = card)
}
}
}
}
@Composable
private fun RecipeThumbnail() {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(ThumbnailHeight),
) {
ThumbnailPlaceholder()
Image(
painter = painterResource(Res.drawable.sample_recipe),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun ThumbnailPlaceholder() {
Box(
modifier =
Modifier
.fillMaxSize()
.background(RecipeTheme.colors.surfaceGlass),
)
}
@Composable
private fun RecipeMetaRow(card: RecipeCardUi) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
MetaItem(
icon = Lucide.Clock,
label = stringResource(Res.string.recipe_card_minutes_format, card.cookingMinutes),
)
MetaItem(
icon = Lucide.Flame,
label = stringResource(Res.string.recipe_card_kcal_format, card.kcal),
)
}
}
@Composable
private fun MetaItem(
icon: ImageVector,
label: String,
) {
val colors = RecipeTheme.colors
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
UnstyledIcon(
imageVector = icon,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(MetaIconSize),
)
BasicText(
text = label,
style =
RecipeTheme.typography.label.copy(
color = colors.contentMuted,
fontSize = CardMetaTextSize,
lineHeight = CardMetaLineHeight,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private val MinRecipeCardWidth = 104.dp
private val GridBottomPadding = 96.dp
private val CardHeight = 190.dp
private val ThumbnailHeight = 87.dp
private val CardCornerRadius = 17.dp
private val CardContentPadding = 10.dp
private val CardElevation = 3.dp
private val MetaIconSize = 11.dp
private val CardTitleTextSize = 11.sp
private val CardTitleLineHeight = 14.sp
private val CardMetaTextSize = 10.sp
private val CardMetaLineHeight = 13.sp

View File

@@ -0,0 +1,5 @@
package dev.ulfrx.recipe.ui.screens.search.catalog
data class RecipeCatalogState(
val cards: List<RecipeCardUi> = emptyList(),
)

View File

@@ -0,0 +1,11 @@
package dev.ulfrx.recipe.ui.screens.search.catalog
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class RecipeCatalogViewModel : ViewModel() {
private val _state = MutableStateFlow(RecipeCatalogState(cards = sampleRecipeCatalogCards))
val state: StateFlow<RecipeCatalogState> = _state.asStateFlow()
}

View File

@@ -0,0 +1,95 @@
package dev.ulfrx.recipe.ui.screens.search.catalog
internal val sampleRecipeCatalogCards: List<RecipeCardUi> =
listOf(
RecipeCardUi(
id = "rcp_nalesniki",
title = "Naleśniki z twarogiem",
cookingMinutes = 25,
kcal = 320,
),
RecipeCardUi(
id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami",
cookingMinutes = 10,
kcal = 280,
),
RecipeCardUi(
id = "rcp_spaghetti",
title = "Spaghetti bolognese",
cookingMinutes = 40,
kcal = 540,
),
RecipeCardUi(
id = "rcp_pierogi",
title = "Pierogi ruskie",
cookingMinutes = 90,
kcal = 460,
),
RecipeCardUi(
id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem",
cookingMinutes = 5,
kcal = 210,
),
RecipeCardUi(
id = "rcp_schabowy",
title = "Schabowy z ziemniakami",
cookingMinutes = 60,
kcal = 720,
),
RecipeCardUi(
id = "rcp_salatka_grecka",
title = "Sałatka grecka",
cookingMinutes = 15,
kcal = 310,
),
RecipeCardUi(
id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem",
cookingMinutes = 35,
kcal = 240,
),
RecipeCardUi(
id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati",
cookingMinutes = 45,
kcal = 580,
),
RecipeCardUi(
id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem",
cookingMinutes = 8,
kcal = 290,
),
RecipeCardUi(
id = "rcp_risotto",
title = "Risotto z grzybami leśnymi",
cookingMinutes = 35,
kcal = 470,
),
RecipeCardUi(
id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami",
cookingMinutes = 20,
kcal = 430,
),
RecipeCardUi(
id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe",
cookingMinutes = 5,
kcal = 180,
),
RecipeCardUi(
id = "rcp_losos",
title = "Łosoś pieczony z brokułami",
cookingMinutes = 30,
kcal = 510,
),
RecipeCardUi(
id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami",
cookingMinutes = 55,
kcal = 390,
),
)

View File

@@ -9,6 +9,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -22,6 +23,8 @@ import dev.ulfrx.recipe.navigation.TabNavigator
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState 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.OverlayDismisser
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.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
@@ -32,10 +35,15 @@ 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 catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle() val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState() val backdropState = rememberGlassBackdropState()
val overlayDismisser = remember { OverlayDismisser() }
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) { CompositionLocalProvider(
LocalGlassBackdropState provides backdropState,
LocalOverlayDismisser provides overlayDismisser,
) {
Box( Box(
modifier = modifier =
modifier modifier
@@ -56,7 +64,10 @@ fun AppShell(modifier: Modifier = Modifier) {
label = "AppShell body", label = "AppShell body",
) { searchOpen -> ) { searchOpen ->
if (searchOpen) { if (searchOpen) {
SearchScreen(viewModel = searchVm) SearchScreen(
viewModel = searchVm,
catalogGridState = catalogGridState,
)
} else { } else {
RootNavDisplay( RootNavDisplay(
navigator = navigator, navigator = navigator,
@@ -68,7 +79,10 @@ fun AppShell(modifier: Modifier = Modifier) {
ShellBottomChrome( ShellBottomChrome(
activeTab = navigator.activeTab, activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab, onTabSelect = { tab ->
overlayDismisser.dismissAll()
navigator.selectTab(tab)
},
search = search =
SearchHandlers( SearchHandlers(
state = searchState, state = searchState,

View File

@@ -111,7 +111,7 @@ fun ShellBottomChrome(
) { ) {
AnimatedContent( AnimatedContent(
targetState = search.state.isOpen, targetState = search.state.isOpen,
modifier = Modifier.fillMaxWidth().height(63.dp), modifier = Modifier.fillMaxWidth().height(DockBandHeight),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
// Exit is instant (no fade-out): the outgoing chrome cell — dock // Exit is instant (no fade-out): the outgoing chrome cell — dock
// OR search pill row — may still be playing its press animation // OR search pill row — may still be playing its press animation
@@ -165,9 +165,9 @@ private fun DockRow(
collapsed = false, collapsed = false,
onTabSelect = onTabSelect, onTabSelect = onTabSelect,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
height = 63.dp, height = DockBandHeight,
) )
Box(modifier = Modifier.size(63.dp)) { Box(modifier = Modifier.size(DockBandHeight)) {
FloatingSearchButton(onClick = onSearchTap) FloatingSearchButton(onClick = onSearchTap)
} }
} }

View File

@@ -0,0 +1,21 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
internal val DockBandHeight: Dp = 63.dp
@Composable
fun rememberShellChromeHeight(): Dp {
val spacing = RecipeTheme.spacing
val navBottom =
with(LocalDensity.current) {
(WindowInsets.navigationBars.getBottom(this) / 2).toDp()
}
return navBottom + spacing.xs + DockBandHeight + spacing.sm
}

View File

@@ -104,7 +104,8 @@ private fun DockBarExpanded(
} }
}, },
) { ) {
val anim = rememberDockOverlayAnimations( val anim =
rememberDockOverlayAnimations(
pressState = pressState, pressState = pressState,
activeIndex = activeIndex, activeIndex = activeIndex,
tabBounds = tabBounds, tabBounds = tabBounds,

View File

@@ -28,11 +28,11 @@ import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
private val PressOverlayBleed = 4.dp private val PressOverlayBleed = 4.dp
private const val SlideOutwardStiffness = Spring.StiffnessMediumLow private const val SLIDE_OUTWARD_STIFFNESS = Spring.StiffnessMediumLow
private const val SlideSettleStiffness = Spring.StiffnessHigh private const val SLIDE_SETTLE_STIFFNESS = Spring.StiffnessHigh
private const val OverlayFadeInDurationMs = 120 private const val OVERLAY_FADE_IN_DURATION_MS = 120
private const val OverlayFadeOutDurationMs = 40 private const val OVERLAY_FADE_OUT_DURATION_MS = 40
private const val SettleEpsilonPx = 0.5f private const val SETTLE_EPSILON_PX = 0.5f
internal data class DockOverlayAnimations( internal data class DockOverlayAnimations(
val overlayCenterX: Float, val overlayCenterX: Float,
@@ -72,23 +72,27 @@ internal fun rememberDockOverlayAnimations(
activeCenterX, activeCenterX,
spring( spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SlideSettleStiffness, stiffness = SLIDE_SETTLE_STIFFNESS,
visibilityThreshold = SettleEpsilonPx, visibilityThreshold = SETTLE_EPSILON_PX,
), ),
) )
} }
!wasPressed -> { !wasPressed -> {
wasPressed = true wasPressed = true
centerAnim.animateTo( centerAnim.animateTo(
clampedPressX, clampedPressX,
spring( spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SlideOutwardStiffness, stiffness = SLIDE_OUTWARD_STIFFNESS,
visibilityThreshold = SettleEpsilonPx, visibilityThreshold = SETTLE_EPSILON_PX,
), ),
) )
} }
else -> centerAnim.snapTo(clampedPressX)
else -> {
centerAnim.snapTo(clampedPressX)
}
} }
} }
@@ -101,13 +105,14 @@ internal fun rememberDockOverlayAnimations(
activeAlphaAnim.snapTo(0f) activeAlphaAnim.snapTo(0f)
overlayAlphaAnim.animateTo( overlayAlphaAnim.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = tween(OverlayFadeInDurationMs, easing = FastOutSlowInEasing), animationSpec = tween(OVERLAY_FADE_IN_DURATION_MS, easing = FastOutSlowInEasing),
) )
} else { } else {
if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect
releaseSlideStartX = centerAnim.value releaseSlideStartX = centerAnim.value
if (overlayAlphaAnim.value < 1f) { if (overlayAlphaAnim.value < 1f) {
val tailMs = ((1f - overlayAlphaAnim.value) * OverlayFadeInDurationMs) val tailMs =
((1f - overlayAlphaAnim.value) * OVERLAY_FADE_IN_DURATION_MS)
.toInt() .toInt()
.coerceAtLeast(0) .coerceAtLeast(0)
if (tailMs > 0) { if (tailMs > 0) {
@@ -116,19 +121,19 @@ internal fun rememberDockOverlayAnimations(
} }
snapshotFlow { snapshotFlow {
!centerAnim.isRunning && !centerAnim.isRunning &&
abs(centerAnim.value - activeCenterXState.value) < SettleEpsilonPx abs(centerAnim.value - activeCenterXState.value) < SETTLE_EPSILON_PX
}.first { it } }.first { it }
coroutineScope { coroutineScope {
launch { launch {
overlayAlphaAnim.animateTo( overlayAlphaAnim.animateTo(
targetValue = 0f, targetValue = 0f,
animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing), animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
) )
} }
launch { launch {
activeAlphaAnim.animateTo( activeAlphaAnim.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing), animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
) )
} }
} }
@@ -136,15 +141,19 @@ internal fun rememberDockOverlayAnimations(
} }
} }
val releaseSlideProgress = run { val releaseSlideProgress =
run {
val start = releaseSlideStartX val start = releaseSlideStartX
if (start == null) { if (start == null) {
0f 0f
} else { } else {
val target = activeCenterXState.value val target = activeCenterXState.value
val total = abs(target - start) val total = abs(target - start)
if (total < 1f) 0f if (total < 1f) {
else (abs(centerAnim.value - start) / total).coerceIn(0f, 1f) 0f
} else {
(abs(centerAnim.value - start) / total).coerceIn(0f, 1f)
}
} }
} }
val overlayPeakProgress = overlayAlphaAnim.value * (1f - releaseSlideProgress) val overlayPeakProgress = overlayAlphaAnim.value * (1f - releaseSlideProgress)

View File

@@ -13,18 +13,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.util.lerp
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times import androidx.compose.ui.unit.times
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.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val PressOverlayVerticalInset = 0.dp private val PressOverlayVerticalInset = 0.dp
private val ActiveIndicatorVerticalInset = 5.dp private val ActiveIndicatorVerticalInset = 5.dp
private const val PressOverlayScale = 1.22f private const val PRESS_OVERLAY_SCALE = 1.22f
@Composable @Composable
internal fun DockSubstrate(cornerRadius: Dp) { internal fun DockSubstrate(cornerRadius: Dp) {
@@ -49,7 +49,8 @@ internal fun DockActiveIndicatorLayer(
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density) val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
Box( Box(
modifier = Modifier modifier =
Modifier
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) } .offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
.width(with(density) { bbox.widthPx.toDp() }) .width(with(density) { bbox.widthPx.toDp() })
.fillMaxHeight() .fillMaxHeight()
@@ -77,13 +78,14 @@ internal fun DockPressOverlayLayer(
val dockHeightPx = with(density) { dockHeight.toPx() } val dockHeightPx = with(density) { dockHeight.toPx() }
val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() } val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() }
val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f
val scaleX = lerp(1f, PressOverlayScale, overlayPeakProgress) val scaleX = lerp(1f, PRESS_OVERLAY_SCALE, overlayPeakProgress)
val scaleY = lerp(activeStartScaleY, PressOverlayScale, overlayPeakProgress) val scaleY = lerp(activeStartScaleY, PRESS_OVERLAY_SCALE, overlayPeakProgress)
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2 val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
val leftPx = overlayCenterX - overlayWidthPx / 2f val leftPx = overlayCenterX - overlayWidthPx / 2f
GlassSurface( GlassSurface(
modifier = Modifier modifier =
Modifier
.offset { IntOffset(leftPx.roundToInt(), 0) } .offset { IntOffset(leftPx.roundToInt(), 0) }
.width(with(density) { overlayWidthPx.toDp() }) .width(with(density) { overlayWidthPx.toDp() })
.fillMaxHeight() .fillMaxHeight()
@@ -91,10 +93,8 @@ internal fun DockPressOverlayLayer(
.graphicsLayer { .graphicsLayer {
this.scaleX = scaleX this.scaleX = scaleX
this.scaleY = scaleY this.scaleY = scaleY
} }.alpha(overlayAlpha),
.alpha(overlayAlpha),
cornerRadius = cornerRadius, cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress, glassStyle = RecipeTheme.glass.dockPress,
tint = RecipeTheme.colors.surfaceGlassOverlay,
) {} ) {}
} }

View File

@@ -33,8 +33,8 @@ import kotlin.math.roundToInt
private val DockTabIconSize = 18.dp private val DockTabIconSize = 18.dp
private val DockTabIconLabelGap = 2.dp private val DockTabIconLabelGap = 2.dp
private const val DockTabLabelFontSizeSp = 11 private const val DOCK_TAB_LABEL_FONT_SIZE_SP = 11
private const val DockTabLabelLineHeightSp = 13 private const val DOCK_TAB_LABEL_LINE_HEIGHT_SP = 13
@Composable @Composable
internal fun DockTabRow( internal fun DockTabRow(
@@ -53,7 +53,8 @@ internal fun DockTabRow(
) { ) {
destinations.forEachIndexed { index, destination -> destinations.forEachIndexed { index, destination ->
val cellBounds = tabBounds[index] val cellBounds = tabBounds[index]
val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) { val contentOffsetPx =
if (cellBounds != null && dockWidthPx > 0f) {
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density) val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
bbox.centerPx - cellCenterX bbox.centerPx - cellCenterX
@@ -65,7 +66,8 @@ internal fun DockTabRow(
isActive = index == activeIndex, isActive = index == activeIndex,
contentOffsetPx = contentOffsetPx, contentOffsetPx = contentOffsetPx,
onSelect = { onTabSelectFromA11y(destination) }, onSelect = { onTabSelectFromA11y(destination) },
modifier = Modifier modifier =
Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
@@ -92,9 +94,10 @@ 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.semantics { modifier =
modifier.semantics {
role = Role.Tab role = Role.Tab
selected = isActive selected = isActive
contentDescription = a11yLabel contentDescription = a11yLabel
@@ -119,10 +122,11 @@ private fun DockTabItem(
Spacer(modifier = Modifier.size(DockTabIconLabelGap)) Spacer(modifier = Modifier.size(DockTabIconLabelGap))
BasicText( BasicText(
text = label, text = label,
style = RecipeTheme.typography.label.copy( style =
RecipeTheme.typography.label.copy(
color = tint, color = tint,
fontSize = DockTabLabelFontSizeSp.sp, fontSize = DOCK_TAB_LABEL_FONT_SIZE_SP.sp,
lineHeight = DockTabLabelLineHeightSp.sp, lineHeight = DOCK_TAB_LABEL_LINE_HEIGHT_SP.sp,
), ),
) )
} }

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

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
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.shopping_buy_count
@Composable
fun ShoppingHorizonPill(
selectedDate: LocalDate,
expanded: Boolean,
today: LocalDate,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
HorizonCalendarPill(
selectedDate = selectedDate,
expanded = expanded,
today = today,
onExpandedChange = onExpandedChange,
onSelectDate = onSelectDate,
trailing = {
BasicText(
text = stringResource(Res.string.shopping_buy_count, DUMMY_TO_BUY),
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.contentMuted),
maxLines = 1,
)
},
modifier = modifier,
)
}
private const val DUMMY_TO_BUY = 12

View File

@@ -1,6 +1,5 @@
package dev.ulfrx.recipe.ui.screens.shopping package dev.ulfrx.recipe.ui.screens.shopping
import androidx.compose.foundation.background
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.Column import androidx.compose.foundation.layout.Column
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
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
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_shopping_subtitle
import recipe.composeapp.generated.resources.empty_shopping_title import recipe.composeapp.generated.resources.empty_shopping_title
import recipe.composeapp.generated.resources.shell_tab_shopping import recipe.composeapp.generated.resources.shell_tab_shopping
/**
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
* empty body with the shopping list + session UI.
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable @Composable
fun ShoppingScreen(viewModel: ShoppingViewModel) { fun ShoppingScreen(viewModel: ShoppingViewModel) {
@Suppress("UNUSED_VARIABLE") val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle() val today = remember { todayInSystemTz() }
Box( BottomOverlayScaffold(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), open = horizonState.isCalendarOpen,
onDismiss = viewModel.horizon::close,
bottomInset = rememberShellChromeHeight(),
overlay = {
ShoppingHorizonPill(
selectedDate = horizonState.selectedDate,
expanded = horizonState.isCalendarOpen,
today = today,
onExpandedChange = viewModel.horizon::setOpen,
onSelectDate = viewModel.horizon::select,
)
},
) { ) {
Column( Column(
modifier = modifier =

View File

@@ -1,19 +1,8 @@
package dev.ulfrx.recipe.ui.screens.shopping package dev.ulfrx.recipe.ui.screens.shopping
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [ShoppingScreen]. Phase 2.1 ships only the empty state. Phase 9
* (Shopping List & Session Log) extends this with list items + session actions.
*/
data class ShoppingState(
val isEmpty: Boolean = true,
)
class ShoppingViewModel : ViewModel() { class ShoppingViewModel : ViewModel() {
private val _state = MutableStateFlow(ShoppingState()) val horizon = HorizonCalendarHolder()
val state: StateFlow<ShoppingState> = _state.asStateFlow()
} }

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(0xFF0F1113), background = Color(0xFF1E2024),
surface = Color(0xFF1A1D21), surface = Color(0xFF2A2D31),
surfaceGlass = Color(0xFF3A3D42).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(0xFF2A2D31), 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,31 +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
data object RecipeGlass { data class RecipeGlass(
val menu: RecipeGlassStyle = val dock: RecipeGlassStyle,
RecipeGlassStyle( val dockPress: RecipeGlassStyle,
refraction = 0.10f, val button: RecipeGlassStyle,
curve = 0.5f, val panel: RecipeGlassStyle,
edge = 0.04f, val chipOnGlass: RecipeGlassStyle,
dispersion = 0.05f, )
saturation = 0.5f,
contrast = 1.3f,
frost = 15.dp,
)
val dockPress: RecipeGlassStyle = fun recipeGlassFor(colors: RecipeColors): RecipeGlass =
RecipeGlassStyle( RecipeGlass(
refraction = 0.20f, dock = RecipeGlassStyle(
curve = 0.05f, refraction = 0.5f,
edge = 0.04f, curve = 0.4f,
dispersion = 0.03f, edge = 0.03f,
saturation = 0.6f, dispersion = 0f,
contrast = 1.8f, saturation = 1f,
contrast = 1f,
frost = 2.dp,
tint = colors.surfaceGlass,
),
dockPress = RecipeGlassStyle(
refraction = 0f,
curve = 0f,
edge = 0.03f,
dispersion = 0.0f,
saturation = 1f,
contrast = 1f,
frost = 0.dp, frost = 0.dp,
),
button = RecipeGlassStyle(
refraction = 0.3f,
curve = 0.2f,
edge = 0.03f,
dispersion = 0.5f,
saturation = 1f,
contrast = 0.85f,
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,
contrast = 1.5f,
frost = 5.dp,
),
) )
}
data class RecipeGlassStyle( data class RecipeGlassStyle(
val refraction: Float, val refraction: Float,
@@ -35,4 +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,
) )

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

@@ -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" }

View File

@@ -23,11 +23,15 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization // Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
// is the only allowed runtime dependency in shared/commonMain — D-19 / INFRA-06 // and kotlinx.datetime are the only allowed runtime dependencies in
// forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers // shared/commonMain — D-19 / INFRA-06 forbids Ktor, Compose, SQLDelight,
// (composeApp, server) inherit the @Serializable runtime without each // Koin, Kermit. `api(...)` so consumers (composeApp, server) inherit the
// re-declaring it. // @Serializable runtime + datetime types without each re-declaring them.
api(libs.kotlinx.serializationJson) api(libs.kotlinx.serializationJson)
// Domain types need Instant (SyncMeta.updatedAt/createdAt/deletedAt) and
// LocalDate (PlanEntry.date). kotlinx.datetime is the project's locked
// datetime lib per CLAUDE.md; pure types, no platform deps.
api(libs.kotlinx.datetime)
} }
commonTest.dependencies { commonTest.dependencies {

View File

@@ -0,0 +1,150 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.serialization.Serializable
// ── Value-typed metadata ──────────────────────────────────────────────────
/**
* Macros per 100 g (or 100 ml for liquid ingredients). Daily-total aggregation
* (PLAN-13) reads these from ingredients/products and scales by the canonical
* quantity at meal time.
*/
@Serializable
public data class NutritionPer100(
public val kcal: Double,
public val protein: Double,
public val fat: Double,
public val carbs: Double,
)
/**
* Typical retail pack for an ingredient or a specific product (e.g.
* `PurchasePack(125.0, "125 g")`). Shopping list rounding uses this to suggest
* realistic pack-sized purchases instead of raw recipe weights.
*/
@Serializable
public data class PurchasePack(
public val amount: Double,
public val label: String,
)
// ── Household-scoped taxonomy ────────────────────────────────────────────
//
// IngredientCategory and MealSlot are user-curated per household: each
// household ships with a Polish default list at creation (Phase 3 server
// concern) and can rename / reorder / extend freely. Both LWW-sync via SyncMeta
// the same way other household data does.
/**
* A pantry / shopping grouping the household uses (`Pieczywo`, `Nabiał`,
* `Mięso i ryby`, …). [ordinal] drives display order in the pantry and
* shopping list. [name] is localized per the household's locale palette.
*/
@Serializable
public data class IngredientCategory(
public val id: IngredientCategoryId,
public val sync: SyncMeta,
public val name: LocalizedString,
public val ordinal: Int,
)
/**
* A meal-time slot a household plans into (`Śniadanie`, `Drugie śniadanie`,
* `Obiad`, `Przekąska`, `Kolacja`, plus anything custom). [ordinal] drives the
* within-day order in the planner. Slots are referenced by [PlanEntry.slotId].
*
* Recipes don't pin themselves to specific slots — slot affinity is a UI-layer
* match between [Recipe.tags] and [name] localized values, so households can
* rename slots without invalidating the catalog.
*/
@Serializable
public data class MealSlot(
public val id: MealSlotId,
public val sync: SyncMeta,
public val name: LocalizedString,
public val ordinal: Int,
)
// ── Catalog entities (server-owned in v1, household-editable later) ──────
//
// Ingredient / Product / Recipe carry SyncMeta so that future client-side
// catalog writes land on the same LWW path as everything else; v1 only reads
// them on the client.
/**
* A catalog-level ingredient (mąka, oliwa, jajko, …). [pantryUnit] is the
* canonical unit this ingredient is tracked in for pantry math; recipe lines
* targeting this ingredient must produce a [Quantity] in the same unit (or one
* that converts cleanly — pieces ↔ grams via [weightPerPieceG]).
*
* [weightPerPieceG] is only meaningful when [pantryUnit] == [MeasurementUnit.PIECE];
* leaving it null on a piece-tracked ingredient blocks nutrition aggregation.
* [purchasePack] feeds shopping rounding; [nutritionPer100] feeds daily totals.
*/
@Serializable
public data class Ingredient(
public val id: IngredientId,
public val sync: SyncMeta,
public val name: LocalizedString,
public val categoryId: IngredientCategoryId,
public val pantryUnit: MeasurementUnit,
public val weightPerPieceG: Double? = null,
public val purchasePack: PurchasePack? = null,
public val nutritionPer100: NutritionPer100? = null,
public val imagePath: String? = null,
)
/**
* A branded / packaged variant of an [Ingredient]. Products carry their own
* [nutritionPer100] because brand A's twaróg has different macros than brand
* B's, and per-entry [PlanCustomization.overrides] can pick a specific product.
* [brand] is plain `String?` — brand names are proper nouns and don't translate.
*/
@Serializable
public data class Product(
public val id: ProductId,
public val sync: SyncMeta,
public val ingredientId: IngredientId,
public val name: LocalizedString,
public val brand: String? = null,
public val pack: PurchasePack,
public val nutritionPer100: NutritionPer100? = null,
public val imagePath: String? = null,
)
/**
* One ingredient line on a recipe. [alternatives] is metadata only — the
* planner UI surfaces these as suggested swaps, but the actual per-entry
* substitution is recorded in [IngredientCustomization.substituteWith].
*/
@Serializable
public data class RecipeIngredient(
public val ingredientId: IngredientId,
public val quantity: Quantity,
public val alternatives: List<IngredientId> = emptyList(),
)
/**
* Catalog-level recipe definition. Synced via the same LWW path as household
* data so future client-side recipe editing lands additively. Recipes are
* server-seeded in v1 and read-only on the client.
*
* No `allowedSlots` field — slot affinity is a UI-layer concern using [tags]
* matched against [MealSlot.name] localized values. Recipes ship with Polish
* slot tags so default households work out of the box.
*
* [nutritionPerServing] is the cached pre-customization total; the calculator
* skips the per-ingredient walk when no [PlanCustomization] is present.
*/
@Serializable
public data class Recipe(
public val id: RecipeId,
public val sync: SyncMeta,
public val title: LocalizedString,
public val minutes: Int,
public val tags: List<String> = emptyList(),
public val steps: List<LocalizedString> = emptyList(),
public val ingredients: List<RecipeIngredient>,
public val nutritionPerServing: NutritionPer100,
public val imagePath: String? = null,
)

View File

@@ -0,0 +1,54 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
// Typed id wrappers. `@Serializable @JvmInline value class` serializes as the
// underlying string in JSON (no `{"raw":"..."}` envelope) and erases to a
// String at runtime on JVM/Native — zero wire-format change, zero overhead,
// full compile-time protection against passing a RecipeId where an
// IngredientId is expected.
@Serializable
@JvmInline
public value class HouseholdId(public val raw: String)
@Serializable
@JvmInline
public value class UserId(public val raw: String)
@Serializable
@JvmInline
public value class RecipeId(public val raw: String)
@Serializable
@JvmInline
public value class IngredientId(public val raw: String)
@Serializable
@JvmInline
public value class ProductId(public val raw: String)
@Serializable
@JvmInline
public value class PlanEntryId(public val raw: String)
@Serializable
@JvmInline
public value class PantryItemId(public val raw: String)
@Serializable
@JvmInline
public value class ShoppingListId(public val raw: String)
@Serializable
@JvmInline
public value class ShoppingItemId(public val raw: String)
@Serializable
@JvmInline
public value class IngredientCategoryId(public val raw: String)
@Serializable
@JvmInline
public value class MealSlotId(public val raw: String)

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.serialization.Serializable
/**
* One stock row for an ingredient — optionally pinned to a specific [Product].
*
* The mockup's hybrid `Number-or-{total, items[]}` storage collapses to
* multiple flat rows: one with `productId == null` for generic stock, plus a
* row per tracked product. Aggregating "how much of X do I have?" is a sum
* across the rows sharing an [ingredientId].
*/
@Serializable
public data class PantryItem(
public val id: PantryItemId,
public val sync: SyncMeta,
public val ingredientId: IngredientId,
public val productId: ProductId? = null,
public val quantity: Quantity,
)
/**
* A named shopping list (most households use the default "Kitchen" list;
* additional lists are user-created). [ordinal] orders multiple lists in the
* UI. [name] is plain `String` — user-supplied free text, not catalog-localized.
*/
@Serializable
public data class ShoppingList(
public val id: ShoppingListId,
public val sync: SyncMeta,
public val name: String,
public val ordinal: Int,
)
/**
* One line on a shopping list. [productId] pins a specific brand when present.
* [checked] tracks "bought" state; checking + later sync into pantry happens at
* the repository layer. [sourceNote] is a free-text provenance hint ("From
* plan", "Ze spiżarni"); the v1 UI surfaces it but doesn't filter on it.
*/
@Serializable
public data class ShoppingItem(
public val id: ShoppingItemId,
public val sync: SyncMeta,
public val listId: ShoppingListId,
public val ingredientId: IngredientId,
public val productId: ProductId? = null,
public val quantity: Quantity,
public val checked: Boolean = false,
public val sourceNote: String? = null,
public val ordinal: Int,
)

View File

@@ -0,0 +1,35 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
/**
* BCP-47 locale tag, e.g. `"pl"`, `"en"`, `"pl-PL"`. Held as a typed value
* class so localized-name maps don't get confused with arbitrary string keys.
*/
@Serializable
@JvmInline
public value class LocaleTag(public val raw: String)
/**
* A user-facing string in one or more locales. Stored as a flat JSON object
* keyed by locale tag (e.g. `{"pl": "Śniadanie", "en": "Breakfast"}`). v1 seed
* data ships with at least the Polish key populated; additional locales are
* additive.
*/
public typealias LocalizedString = Map<LocaleTag, String>
/**
* Resolve a localized string for the requested [locale], falling back to
* [fallback] (default Polish), then to any populated value, then to an empty
* string. The empty-string fallback is intentional — UI code should never crash
* because a translation hasn't shipped yet.
*/
public fun LocalizedString.forLocale(
locale: LocaleTag,
fallback: LocaleTag = LocaleTag("pl"),
): String =
this[locale]
?: this[fallback]
?: values.firstOrNull()
?: ""

View File

@@ -0,0 +1,47 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* One ingredient line on a planned meal. Self-contained — substitutions,
* exclusions, amount overrides, and product picks are not modeled as
* overlays on the recipe; they're just whatever ends up in this list.
*
* A swap (butter → margarine) is just a `PlanIngredient` with the swapped
* `ingredientId`. A removal is absence from the list. An amount tweak is
* the [quantity] field. A pinned brand is [productPick].
*/
@Serializable
public data class PlanIngredient(
public val ingredientId: IngredientId,
public val quantity: Quantity,
public val productPick: ProductId? = null,
)
/**
* One planned meal: a recipe at a date/slot pair, with a fully-materialized
* snapshot of the ingredients as the user planned them. Identity is the UUID
* [PlanEntryId] — never the composite `(date, slot)` — so concurrent edits
* across devices can survive a delete + recreate race without colliding on a
* natural key (PLAN-14).
*
* [recipeId] is provenance only: it powers the "open recipe" link, the "cook
* again" action, and analytics. The recipe's current ingredient list is not
* consulted at read time — [ingredients] is the source of truth for this
* meal. This way, editing a recipe never mutates historic plan entries
* (load-bearing once consumption tracking lands in a later phase).
*
* "Skipped slot" (PLAN-12) is modeled as absence — no [PlanEntry] row exists
* for that `(date, slotId)`. No sentinel field, no sealed sibling.
*/
@Serializable
public data class PlanEntry(
public val id: PlanEntryId,
public val sync: SyncMeta,
public val date: LocalDate,
public val slotId: MealSlotId,
public val recipeId: RecipeId,
public val servings: Double,
public val ingredients: List<PlanIngredient>,
)

View File

@@ -0,0 +1,36 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
/**
* Per-row sync footer for every household-scoped or catalog entity.
*
* All three timestamps are server-assigned, never client-supplied — the
* monotonic `(updatedAt, id)` pair is the pull cursor, and a client-set
* `updatedAt` would break LWW conflict resolution across devices with drifted
* clocks. [deletedAt] is the soft-delete tombstone; rows with `deletedAt != null`
* still ship through sync so deletes propagate to other devices.
*/
@Serializable
public data class SyncMeta(
public val id: String,
public val householdId: HouseholdId,
public val createdAt: Instant,
public val updatedAt: Instant,
public val deletedAt: Instant? = null,
)
/**
* A household — the tenancy boundary for every per-household entity (plan,
* pantry, shopping list, the household-scoped taxonomy entries in
* [IngredientCategory] / [MealSlot]). Members share a single household; v1 has
* one active household per user.
*/
@Serializable
public data class Household(
public val id: HouseholdId,
public val name: String,
public val createdAt: Instant,
public val updatedAt: Instant,
)

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The three canonical units the whole system computes on. Polish vocabulary
* like `"łyżka"`, `"puszka"`, `"szczypta"` is NOT a unit — it lives in
* [Quantity.displayHint] as render-only metadata while the math runs on this
* fixed enum.
*/
@Serializable
public enum class MeasurementUnit {
@SerialName("gram")
GRAM,
@SerialName("milliliter")
MILLILITER,
@SerialName("piece")
PIECE,
}
/**
* Canonical [amount] in a fixed [unit], with an optional human-readable
* [displayHint] for UI rendering.
*
* The hint lets a recipe say "2 łyżki oleju" (stored as
* `Quantity(30.0, MILLILITER, {"pl": "2 łyżki"})`) and still aggregate cleanly
* with another row's `100ml` because both are millilitres internally. Catalog
* ingest is responsible for choosing the canonical amount; the domain model
* just stores the result.
*/
@Serializable
public data class Quantity(
public val amount: Double,
public val unit: MeasurementUnit,
public val displayHint: LocalizedString? = null,
)

View File

@@ -0,0 +1,281 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Wire-format contract for the reshaped domain model.
*
* The sync engine (Phase 4) stores these shapes verbatim into Postgres JSONB
* columns; the planner UI (Phase 6/7) round-trips them through SQLDelight; a
* future client-side catalog editor would write through the same path. So the
* JSON keys, value-class flattening, enum casing, and default-omission behavior
* asserted below are load-bearing.
*/
class SerializationRoundTripTest {
private val json = Json { encodeDefaults = false }
private val pl = LocaleTag("pl")
private val en = LocaleTag("en")
// ── IDs ──────────────────────────────────────────────────────────────
@Test
fun `typed IDs serialize as bare strings instead of wrapper objects`() {
val encoded = json.encodeToString(RecipeId.serializer(), RecipeId("rcp_007"))
assertEquals("\"rcp_007\"", encoded)
val decoded = json.decodeFromString(RecipeId.serializer(), encoded)
assertEquals(RecipeId("rcp_007"), decoded)
}
// ── Localization ─────────────────────────────────────────────────────
@Test
fun `LocaleTag serializes as a bare string key`() {
// LocaleTag must serialize as a string so LocalizedString JSON keys
// read like {"pl":"Śniadanie"} — not {"raw":"pl"} wrappers.
val encoded = json.encodeToString(LocaleTag.serializer(), pl)
assertEquals("\"pl\"", encoded)
}
@Test
fun `LocalizedString forLocale falls back to Polish then to any value`() {
val name: LocalizedString = mapOf(pl to "Śniadanie", en to "Breakfast")
assertEquals("Śniadanie", name.forLocale(pl))
assertEquals("Breakfast", name.forLocale(en))
val onlyPolish: LocalizedString = mapOf(pl to "Obiad")
assertEquals("Obiad", onlyPolish.forLocale(en))
val empty: LocalizedString = emptyMap()
assertEquals("", empty.forLocale(pl))
}
// ── Units ────────────────────────────────────────────────────────────
@Test
fun `MeasurementUnit serializes as lowercase English wire names`() {
assertEquals("\"gram\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.GRAM))
assertEquals("\"milliliter\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.MILLILITER))
assertEquals("\"piece\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.PIECE))
}
@Test
fun `Quantity with displayHint encodes hint as flat locale map`() {
val q = Quantity(amount = 30.0, unit = MeasurementUnit.MILLILITER, displayHint = mapOf(pl to "2 łyżki"))
val encoded = json.encodeToString(Quantity.serializer(), q)
assertEquals(
"{\"amount\":30.0,\"unit\":\"milliliter\",\"displayHint\":{\"pl\":\"2 łyżki\"}}",
encoded,
)
val decoded = json.decodeFromString(Quantity.serializer(), encoded)
assertEquals(q, decoded)
}
@Test
fun `Quantity without displayHint omits the null field on the wire`() {
// Phase 4 syncs these into JSONB; omitting nulls keeps the payload
// small and lets future fields default cleanly on the decoder side.
val q = Quantity(amount = 100.0, unit = MeasurementUnit.GRAM)
val encoded = json.encodeToString(Quantity.serializer(), q)
assertEquals("{\"amount\":100.0,\"unit\":\"gram\"}", encoded)
assertEquals(q, json.decodeFromString(Quantity.serializer(), encoded))
}
// ── PlanEntry — the full customization shape ─────────────────────────
@Test
fun `PlanEntry carries a materialized ingredient snapshot and round trips`() {
// The snapshot model collapses substitutions, exclusions, amount overrides,
// product picks, and added ingredients into a single ingredient list. The
// recipe's original list is never consulted at read time, so each of these
// "customization kinds" is just whatever ends up here:
// - butter → margarine swap: margarine appears, butter doesn't
// - salt excluded: salt simply isn't in the list
// - flour amount override: the quantity here IS the amount
// - sugar with pinned product: productPick set on the sugar row
// - oil added (not in the recipe): just another row
val flour = IngredientId("ing_flour")
val margarine = IngredientId("ing_margarine")
val sugar = IngredientId("ing_sugar")
val oil = IngredientId("ing_oil")
val piatnica = ProductId("prd_piatnica_serek")
val entry =
PlanEntry(
id = PlanEntryId("pe_001"),
sync =
SyncMeta(
id = "pe_001",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-05-19T08:00:00Z"),
updatedAt = Instant.parse("2026-05-19T08:00:00Z"),
),
date = LocalDate(2026, 5, 19),
slotId = MealSlotId("slot_breakfast"),
recipeId = RecipeId("rcp_pancakes"),
servings = 2.0,
ingredients =
listOf(
PlanIngredient(
ingredientId = flour,
quantity =
Quantity(
amount = 250.0,
unit = MeasurementUnit.GRAM,
displayHint = mapOf(pl to "2 szklanki"),
),
),
PlanIngredient(
ingredientId = margarine,
quantity = Quantity(amount = 50.0, unit = MeasurementUnit.GRAM),
),
PlanIngredient(
ingredientId = sugar,
quantity = Quantity(amount = 30.0, unit = MeasurementUnit.GRAM),
productPick = piatnica,
),
PlanIngredient(
ingredientId = oil,
quantity =
Quantity(
amount = 15.0,
unit = MeasurementUnit.MILLILITER,
displayHint = mapOf(pl to "1 łyżka"),
),
),
),
)
val encoded = json.encodeToString(PlanEntry.serializer(), entry)
val decoded = json.decodeFromString(PlanEntry.serializer(), encoded)
assertEquals(entry, decoded)
// Spot-check wire shape: typed IDs are bare strings inside the payload
// (otherwise the JSONB column gets ugly and queries are unportable).
assertTrue(encoded.contains("\"recipeId\":\"rcp_pancakes\""), "recipeId should serialize as a bare string: $encoded")
assertTrue(encoded.contains("\"slotId\":\"slot_breakfast\""), "slotId should serialize as a bare string: $encoded")
assertTrue(encoded.contains("\"productPick\":\"prd_piatnica_serek\""), "productPick should serialize as a bare string: $encoded")
}
@Test
fun `PlanIngredient without productPick omits the null field`() {
val ingredient =
PlanIngredient(
ingredientId = IngredientId("ing_flour"),
quantity = Quantity(amount = 100.0, unit = MeasurementUnit.GRAM),
)
val encoded = json.encodeToString(PlanIngredient.serializer(), ingredient)
assertEquals(
"{\"ingredientId\":\"ing_flour\",\"quantity\":{\"amount\":100.0,\"unit\":\"gram\"}}",
encoded,
)
assertEquals(ingredient, json.decodeFromString(PlanIngredient.serializer(), encoded))
}
// ── Catalog ──────────────────────────────────────────────────────────
@Test
fun `Ingredient with multi-locale name and required pantryUnit round trips`() {
val ingredient =
Ingredient(
id = IngredientId("ing_olej"),
sync =
SyncMeta(
id = "ing_olej",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-04-01T00:00:00Z"),
updatedAt = Instant.parse("2026-04-01T00:00:00Z"),
),
name = mapOf(pl to "Olej rzepakowy", en to "Rapeseed oil"),
categoryId = IngredientCategoryId("cat_suche"),
pantryUnit = MeasurementUnit.MILLILITER,
purchasePack = PurchasePack(amount = 1000.0, label = "1 l"),
nutritionPer100 = NutritionPer100(kcal = 884.0, protein = 0.0, fat = 100.0, carbs = 0.0),
)
val encoded = json.encodeToString(Ingredient.serializer(), ingredient)
val decoded = json.decodeFromString(Ingredient.serializer(), encoded)
assertEquals(ingredient, decoded)
assertTrue(
encoded.contains("\"name\":{\"pl\":\"Olej rzepakowy\",\"en\":\"Rapeseed oil\"}"),
"LocalizedString should serialize as a flat locale-keyed object: $encoded",
)
assertTrue(encoded.contains("\"pantryUnit\":\"milliliter\""))
assertTrue(!encoded.contains("\"weightPerPieceG\""), "null weightPerPieceG should be omitted: $encoded")
assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded")
}
@Test
fun `Recipe drops null imagePath but keeps populated localized title and steps`() {
val recipe =
Recipe(
id = RecipeId("rcp_naleśniki"),
sync =
SyncMeta(
id = "rcp_naleśniki",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-04-01T00:00:00Z"),
updatedAt = Instant.parse("2026-04-01T00:00:00Z"),
),
title = mapOf(pl to "Naleśniki", en to "Pancakes"),
minutes = 25,
tags = listOf("śniadanie", "szybkie"),
steps =
listOf(
mapOf(pl to "Wymieszaj mąkę z mlekiem.", en to "Mix flour with milk."),
mapOf(pl to "Smaż na patelni.", en to "Fry on a pan."),
),
ingredients =
listOf(
RecipeIngredient(
ingredientId = IngredientId("ing_flour"),
quantity = Quantity(amount = 200.0, unit = MeasurementUnit.GRAM),
),
),
nutritionPerServing = NutritionPer100(kcal = 320.0, protein = 8.0, fat = 9.0, carbs = 52.0),
)
val encoded = json.encodeToString(Recipe.serializer(), recipe)
val decoded = json.decodeFromString(Recipe.serializer(), encoded)
assertEquals(recipe, decoded)
assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded")
assertTrue(encoded.contains("\"tags\":[\"śniadanie\",\"szybkie\"]"))
}
// ── Sync ─────────────────────────────────────────────────────────────
@Test
fun `SyncMeta omits null deletedAt and surfaces all three timestamps`() {
val sync =
SyncMeta(
id = "pe_001",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-05-19T08:00:00Z"),
updatedAt = Instant.parse("2026-05-19T08:15:00Z"),
)
val encoded = json.encodeToString(SyncMeta.serializer(), sync)
assertTrue(encoded.contains("\"createdAt\":\"2026-05-19T08:00:00Z\""))
assertTrue(encoded.contains("\"updatedAt\":\"2026-05-19T08:15:00Z\""))
assertTrue(!encoded.contains("\"deletedAt\""), "null deletedAt should be omitted: $encoded")
val tombstoned = sync.copy(deletedAt = Instant.parse("2026-05-20T09:00:00Z"))
val tombstoneEncoded = json.encodeToString(SyncMeta.serializer(), tombstoned)
assertTrue(tombstoneEncoded.contains("\"deletedAt\":\"2026-05-20T09:00:00Z\""))
}
}