Compare commits

..

19 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
815c4f4efc Revert back to empty search screen 2026-05-18 22:41:43 +02:00
f1e391ccda Adjust dock overlay 2026-05-18 21:54:42 +02:00
488509db06 Adjust dock overlay animation 2026-05-18 20:11:33 +02:00
ab1630a06b Add calendar in PlannerScreen 2026-05-18 17:02:34 +02:00
fb00df856a Reorganise dockbar code 2026-05-17 22:23:24 +02:00
8eda4b04ee Add home screen 2026-05-17 20:44:25 +02:00
8700d197f0 Search/catalog planning notes 2026-05-16 23:44:55 +02:00
ac5bfbc423 Rework on the dockbar 2026-05-16 23:14:06 +02:00
107 changed files with 7652 additions and 1328 deletions

View File

@@ -74,14 +74,16 @@ dev.ulfrx.recipe/
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
├── ui/
│ ├── theme/ # Colors, typography, Liquid glass style tokens
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
│ ├── components/ # Shared, stateless (VM-free) Recipe-styled composables built on Compose Unstyled where useful
│ └── screens/{recipes,planner,pantry,shopping,recipedetail}/ # Each with screen + ViewModel
├── data/{local,remote,repository}/
└── domain/ # Client-only logic; shared/ handles cross-cutting
```
**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
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

@@ -89,9 +89,11 @@ kotlin {
implementation(libs.ktor.clientLogging)
implementation(libs.ktor.serializationKotlinxJsonMpp)
implementation(libs.kotlinx.serializationJson)
implementation(libs.kotlinx.datetime)
implementation(libs.multiplatform.settings)
implementation(libs.lokksmith.compose)
implementation(libs.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodelNavigation3)
implementation(libs.compose.unstyled)
implementation(libs.compose.icons.lucide)
implementation(libs.liquid)
@@ -107,6 +109,9 @@ kotlin {
// ASWebAuthenticationSession integration directly from Kotlin.
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

@@ -14,33 +14,92 @@
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
<!-- Phase 2.1 — App shell navigation tab labels (UI-03, CONTEXT D-03) -->
<string name="shell_tab_home">Start</string>
<string name="shell_tab_planner">Planer</string>
<string name="shell_tab_recipes">Przepisy</string>
<string name="shell_tab_pantry">Spiżarnia</string>
<string name="shell_tab_shopping">Zakupy</string>
<!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
<string name="search_placeholder">Szukaj…</string>
<!-- Phase 2.1 — Search screen scaffolding (curated landing in State B; 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>
<!-- Phase 2.1 — Search screen scaffolding (results in State C) -->
<string name="search_screen_empty_results_title">Brak wyników</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) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
<string name="search_clear_a11y">Wyczyść</string>
<!-- Phase 2.1 — Dock a11y -->
<string name="dock_expand_a11y">Rozwiń pasek nawigacji</string>
<!-- Phase 2.1 — Empty-state copy (UI-09, CONTEXT D-10/D-11/D-12) -->
<string name="empty_home_title">Tu pojawi się Twój dzień</string>
<string name="empty_home_subtitle">Wkrótce zobaczysz tu podsumowania i propozycje.</string>
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
<string name="empty_recipes_title">Tu pojawi się Twoja książka kucharska</string>
<string name="empty_recipes_subtitle">Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.</string>
<string name="empty_pantry_title">Spiżarnia jest jeszcze pusta</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_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>

View File

@@ -1,27 +1,37 @@
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.mealplaneditor.MealPlanEditorViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
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.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
val shellModule =
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<PlannerViewModel>()
viewModel<RecipesViewModel>()
viewModel<PantryViewModel>()
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<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

@@ -1,47 +1,33 @@
package dev.ulfrx.recipe.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.composables.icons.lucide.BookOpenText
import com.composables.icons.lucide.CalendarDays
import com.composables.icons.lucide.House
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Package
import com.composables.icons.lucide.ShoppingCart
import org.jetbrains.compose.resources.StringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.shell_tab_home
import recipe.composeapp.generated.resources.shell_tab_pantry
import recipe.composeapp.generated.resources.shell_tab_planner
import recipe.composeapp.generated.resources.shell_tab_recipes
import recipe.composeapp.generated.resources.shell_tab_shopping
/**
* The 4 bottom-bar destinations in leftright order per CONTEXT D-03:
* Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
* default landing tab CONTEXT D-03 departs from REQUIREMENTS' literal
* listing order, which research confirmed is non-binding.
*
* Each destination carries its tab's *root* [Screen] (e.g. [Screen.Planner.Home])
* so the shell's [TabNavigator] knows where each tab's back stack starts.
*
* Search is a shell-wide affordance (see
* [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel]) it lives outside
* the tab destinations entirely. This enum is intentionally minimal: route +
* label + icon, nothing about feature affordances.
*/
enum class BottomBarDestination(
enum class DockDestination(
val startDestination: Screen,
val labelRes: StringResource,
val icon: ImageVector,
) {
Home(
startDestination = Screen.Home.Root,
labelRes = Res.string.shell_tab_home,
icon = Lucide.House,
),
Planner(
startDestination = Screen.Planner.Home,
labelRes = Res.string.shell_tab_planner,
icon = Lucide.CalendarDays,
),
Recipes(
startDestination = Screen.Recipes.Home,
labelRes = Res.string.shell_tab_recipes,
icon = Lucide.BookOpenText,
),
Pantry(
startDestination = Screen.Pantry.Home,
labelRes = Res.string.shell_tab_pantry,
@@ -55,7 +41,6 @@ enum class BottomBarDestination(
;
companion object {
/** Default landing tab — CONTEXT D-03. */
val Default: BottomBarDestination = Planner
val Default: DockDestination = Home
}
}

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

@@ -10,12 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import dev.ulfrx.recipe.ui.screens.home.HomeScreen
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.compose.viewmodel.koinViewModel
@@ -45,9 +45,8 @@ import org.koin.compose.viewmodel.koinViewModel
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
* (`saveState=true`/`restoreState=true`).
*
* Phase 5+ introduces detail screens with their own VM scopes; at that point
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
* specifically (passed via `entryDecorators = listOf(...)`).
* Recipe detail is a modal bottom sheet (not a nav destination), so it needs no
* per-entry VM scope here; its VM is hosted by the surface that opens it.
*
* ## Search note
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not
@@ -71,15 +70,16 @@ fun RootNavDisplay(
backStack = navigator.backStackFor(tab),
modifier = Modifier.fillMaxSize(),
onBack = { navigator.goBack(tab) },
entryProvider = entryProvider {
entryProvider =
entryProvider {
entry<Screen.Home.Root> {
val vm: HomeViewModel = koinViewModel()
HomeScreen(viewModel = vm)
}
entry<Screen.Planner.Home> {
val vm: PlannerViewModel = koinViewModel()
PlannerScreen(viewModel = vm)
}
entry<Screen.Recipes.Home> {
val vm: RecipesViewModel = koinViewModel()
RecipesScreen(viewModel = vm)
}
entry<Screen.Pantry.Home> {
val vm: PantryViewModel = koinViewModel()
PantryScreen(viewModel = vm)

View File

@@ -4,25 +4,21 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
/**
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
* back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration).
*
* Screens are grouped by tab so future detail destinations (Phase 5+) slot in
* without polluting the top-level namespace — e.g. `Screen.Recipes.Detail(id)`.
* The grouping is purely a code-organisation convenience; Nav 3 treats each
* leaf as an independent NavKey regardless of nesting.
* Each leaf is `@Serializable` because Nav 3 persists the back stack via
* kotlinx-serialization (process-death restore). Recipes have no tab — they
* land in [RecipeDetail] via the shell-wide search overlay.
*/
sealed interface Screen : NavKey {
sealed interface Home : Screen {
@Serializable
data object Root : Home
}
sealed interface Planner : Screen {
@Serializable
data object Home : Planner
}
sealed interface Recipes : Screen {
@Serializable
data object Home : Recipes
}
sealed interface Pantry : Screen {
@Serializable
data object Home : Pantry
@@ -32,4 +28,12 @@ sealed interface Screen : NavKey {
@Serializable
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

@@ -2,28 +2,27 @@ package dev.ulfrx.recipe.navigation
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf
@Stable
class TabNavigator(
initialTab: BottomBarDestination = BottomBarDestination.Default,
initialTab: DockDestination = DockDestination.Default,
) {
private val backStacks: Map<BottomBarDestination, SnapshotStateList<Screen>> =
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
private val backStacks: Map<DockDestination, SnapshotStateList<Screen>> =
DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
var activeTab: DockDestination by mutableStateOf(initialTab)
private set
val activeBackStack: SnapshotStateList<Screen>
get() = backStacks.getValue(activeTab)
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
backStacks.getValue(tab)
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = backStacks.getValue(tab)
fun selectTab(tab: BottomBarDestination) {
fun selectTab(tab: DockDestination) {
if (tab == activeTab) {
popToRoot(tab)
} else {
@@ -35,14 +34,14 @@ class TabNavigator(
activeBackStack.add(screen)
}
fun goBack(tab: BottomBarDestination = activeTab) {
fun goBack(tab: DockDestination = activeTab) {
val stack = backStacks.getValue(tab)
if (stack.size > 1) {
stack.removeAt(stack.lastIndex)
}
}
private fun popToRoot(tab: BottomBarDestination) {
private fun popToRoot(tab: DockDestination) {
val stack = backStacks.getValue(tab)
while (stack.size > 1) {
stack.removeAt(stack.lastIndex)

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

@@ -0,0 +1,119 @@
package dev.ulfrx.recipe.ui.components.calendar
import kotlinx.datetime.Clock
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
/** Today in the system time zone. */
fun todayInSystemTz(): LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
/** Monday-anchored start of the ISO week containing [date]. */
fun LocalDate.startOfWeekMonday(): LocalDate {
val diff = dayOfWeek.ordinal - DayOfWeek.MONDAY.ordinal
return this.minus(DatePeriod(days = diff))
}
/** First day of the month containing [date]. */
fun LocalDate.startOfMonth(): LocalDate = LocalDate(year, month, 1)
/**
* Returns 42 consecutive days starting from the Monday on/before the 1st of
* [anchor]'s month — i.e., the 6-week visible grid. Anchor's month always
* starts on the first row; trailing rows fill from the next month.
*/
fun monthGridDays(anchor: LocalDate): List<LocalDate> {
val gridStart = anchor.startOfMonth().startOfWeekMonday()
return List(42) { i -> gridStart.plus(DatePeriod(days = i)) }
}
/** Seven days starting from Monday of [anchor]'s week. */
fun weekStripDays(anchor: LocalDate): List<LocalDate> {
val start = anchor.startOfWeekMonday()
return List(7) { i -> start.plus(DatePeriod(days = i)) }
}
/** Formats the visible-period label rendered in the topbar pill. */
fun formatPeriodLabel(
mode: CalendarMode,
anchor: LocalDate,
locale: CalendarLocale,
): String =
when (mode) {
CalendarMode.Month -> {
"${locale.monthsLong[anchor.monthNumber - 1]} ${anchor.year}"
}
CalendarMode.Week -> {
val start = anchor.startOfWeekMonday()
val end = start.plus(DatePeriod(days = 6))
when {
start.year == end.year && start.monthNumber == end.monthNumber -> {
"${start.dayOfMonth}${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
start.year == end.year -> {
"${start.dayOfMonth} ${locale.monthsShort[start.monthNumber - 1]} " +
"${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
else -> {
"${start.dayOfMonth} ${locale.monthsShort[start.monthNumber - 1]} ${start.year} " +
"${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
}
}
}
/** True when [date] is inside the period visible at [anchor] under [mode]. */
fun isInVisiblePeriod(
date: LocalDate,
anchor: LocalDate,
mode: CalendarMode,
): Boolean =
when (mode) {
CalendarMode.Month -> {
date.year == anchor.year && date.monthNumber == anchor.monthNumber
}
CalendarMode.Week -> {
val start = anchor.startOfWeekMonday()
val end = start.plus(DatePeriod(days = 6))
date in start..end
}
}
/**
* Whole-unit offset between [a] and [b] under [mode] (signed b - a). Used to
* map between the surface's pager index and an anchor date.
*/
fun periodsBetween(
a: LocalDate,
b: LocalDate,
mode: CalendarMode,
): Int =
when (mode) {
CalendarMode.Month -> {
(b.year - a.year) * 12 + (b.monthNumber - a.monthNumber)
}
CalendarMode.Week -> {
val startDays = a.startOfWeekMonday().toEpochDays()
val endDays = b.startOfWeekMonday().toEpochDays()
(endDays - startDays) / 7
}
}
/** Advance [date] by [delta] units of [mode]. */
fun LocalDate.plusPeriods(
delta: Int,
mode: CalendarMode,
): LocalDate =
when (mode) {
CalendarMode.Month -> this.plus(DatePeriod(months = delta))
CalendarMode.Week -> this.plus(DatePeriod(days = delta * 7))
}

View File

@@ -0,0 +1,227 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composeunstyled.UnstyledButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
@Composable
internal fun CalendarDayCell(
date: LocalDate,
state: DayState,
isSelected: Boolean,
isToday: Boolean,
onClick: () -> Unit,
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 baseColor = colors.content
val mutedColor = colors.contentMuted
val accent = colors.accent
val background = if (isSelected) accent.copy(alpha = 0.18f) else Color.Transparent
val textColor =
when {
state.disabled -> mutedColor.copy(alpha = 0.45f)
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
isSelected -> accent
else -> baseColor
}
val headerColor =
if (isSelected || state.dimmed || state.disabled) textColor else mutedColor
val ringColor =
when {
isSelected -> accent.copy(alpha = 0.55f)
isToday -> baseColor.copy(alpha = 0.35f)
else -> Color.Transparent
}
val indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = INDICATOR_MUTED_ALPHA)
val cellModifier = modifier.height(cellHeight).fillMaxWidth()
val isClickable = LocalCalendarInteractive.current && !state.disabled
val content: @Composable () -> Unit = {
DayCellInner(
date = date,
textColor = textColor,
numberStyle = numberStyle,
header = header,
headerStyle = headerStyle,
headerColor = headerColor,
indicator = state.indicator,
indicatorColor = indicatorColor,
)
}
if (isClickable) {
UnstyledButton(
onClick = onClick,
backgroundColor = background,
contentColor = textColor,
shape = CircleShape,
borderColor = ringColor,
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
modifier = cellModifier,
content = { content() },
)
} else {
Box(
modifier = cellModifier.dayCellSurface(background, ringColor),
contentAlignment = Alignment.Center,
content = { content() },
)
}
}
@Composable
private fun DayCellInner(
date: LocalDate,
textColor: Color,
numberStyle: TextStyle,
header: String?,
headerStyle: TextStyle,
headerColor: Color,
indicator: Boolean,
indicatorColor: Color,
) {
if (header == null) {
CenteredDayNumber(
date = date,
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(
text = date.dayOfMonth.toString(),
style = numberStyle.copy(color = textColor),
modifier = Modifier.align(Alignment.Center),
)
if (indicator) {
IndicatorDot(
color = indicatorColor,
modifier = Modifier.align(Alignment.Center).offset(y = 11.dp),
)
}
}
}
@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

@@ -0,0 +1,123 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
private val DAY_SPACING = 4.dp
private val WEEK_SPACING = 4.dp
/** Weekday-letter header row. */
@Composable
internal fun WeekdayHeader(
locale: CalendarLocale,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
locale.weekdaysShort.forEach { label ->
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
BasicText(
text = label,
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
fontWeight = FontWeight.Light,
),
)
}
}
}
}
/**
* Seven-day Monday-first strip for [anchor]'s week. All days are in-period so
* the [DayState.dimmed] flag is never set by this composable itself.
*/
@Composable
internal fun WeekStrip(
anchor: LocalDate,
today: LocalDate,
dayState: (LocalDate) -> DayState,
isSelected: (LocalDate) -> Boolean,
onSelect: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val days = weekStripDays(anchor)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
days.forEach { day ->
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = dayState(day),
isSelected = isSelected(day),
isToday = day == today,
onClick = { onSelect(day) },
)
}
}
}
}
/**
* Fixed 6-week grid for [anchor]'s month. Adjacent-month days are auto-marked
* dimmed (caller's [dayState] does not need to set that flag for them).
*/
@Composable
internal fun MonthGrid(
anchor: LocalDate,
today: LocalDate,
dayState: (LocalDate) -> DayState,
isSelected: (LocalDate) -> Boolean,
onSelect: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val days = monthGridDays(anchor)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(WEEK_SPACING),
) {
for (week in 0 until 6) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
for (dayIdx in 0 until 7) {
val day = days[week * 7 + dayIdx]
val inMonth = day.monthNumber == anchor.monthNumber
val resolved = dayState(day)
val effective =
if (!inMonth) resolved.copy(dimmed = true) else resolved
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = effective,
isSelected = isSelected(day),
isToday = day == today,
onClick = { onSelect(day) },
)
}
}
}
}
}
}

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

@@ -0,0 +1,98 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
/**
* Pill button showing the visible period label. Tapping jumps to today and
* selects it. Optional chevron at the end toggles week/month when [expandable]
* is set; the chevron is hidden otherwise so popup variants get a clean pill.
*/
@Composable
internal fun CalendarTopbar(
mode: CalendarMode,
anchor: LocalDate,
today: LocalDate,
selectedDate: LocalDate,
locale: CalendarLocale,
onJumpToToday: () -> Unit,
expandable: Boolean,
onToggleMode: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val onToday = selectedDate == today && isInVisiblePeriod(today, anchor, mode)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
UnstyledButton(
onClick = onJumpToToday,
enabled = !onToday,
backgroundColor = Color.Transparent,
contentColor = colors.content,
shape = CircleShape,
borderColor = colors.separator,
borderWidth = 1.dp,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
modifier = Modifier.defaultMinSize(minHeight = 32.dp),
) {
BasicText(
text = formatPeriodLabel(mode, anchor, locale),
style =
RecipeTheme.typography.label.copy(
color = if (onToday) colors.contentMuted else colors.content,
),
modifier = if (onToday) Modifier.alpha(0.6f) else Modifier,
)
}
if (expandable) {
Spacer(modifier = Modifier.size(8.dp))
UnstyledButton(
onClick = onToggleMode,
backgroundColor = Color.Transparent,
contentColor = colors.content,
shape = CircleShape,
borderColor = colors.separator,
borderWidth = 1.dp,
contentPadding = PaddingValues(6.dp),
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) {
UnstyledIcon(
imageVector = Lucide.ChevronDown,
contentDescription = null,
tint = colors.contentMuted,
modifier =
Modifier
.size(14.dp)
.rotate(if (mode == CalendarMode.Month) 180f else 0f),
)
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Whether the calendar shows a single week strip or the full month grid.
* Planner uses both with a toggle; Pantry/Shopping popups stay on [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"
* outline are handled by the surface itself and must not be set here.
*
* @param dimmed Day belongs to an adjacent month in the 6-week grid.
* @param disabled Day is non-interactive (e.g., past dates in Pantry).
* @param indicator Render a small dot under the date number (e.g., "has meal").
*/
@Immutable
data class DayState(
val dimmed: Boolean = false,
val disabled: Boolean = false,
val indicator: Boolean = false,
)
/**
* Localized strings for the calendar. Hardcoded to Polish in v1 (REQ-LOC-PL).
* Externalize to string resources when other locales arrive.
*/
@Immutable
data class CalendarLocale(
val weekdaysShort: List<String>,
val monthsLong: List<String>,
val monthsShort: List<String>,
) {
companion object {
val PL: CalendarLocale =
CalendarLocale(
weekdaysShort = listOf("pn", "wt", "śr", "cz", "pt", "so", "nd"),
monthsLong =
listOf(
"Styczeń",
"Luty",
"Marzec",
"Kwiecień",
"Maj",
"Czerwiec",
"Lipiec",
"Sierpień",
"Wrzesień",
"Październik",
"Listopad",
"Grudzień",
),
monthsShort =
listOf(
"sty",
"lut",
"mar",
"kwi",
"maj",
"cze",
"lip",
"sie",
"wrz",
"paź",
"lis",
"gru",
),
)
}
}

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,168 @@
package dev.ulfrx.recipe.ui.components.calendar
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
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.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.LocalDate
/**
* Reusable calendar surface for planner, pantry, and shopping. One swipe-able
* paged carousel of week strips or month grids, plus an optional chevron to
* toggle between the two modes.
*
* The composable is **controlled** — anchor/selection/mode live in the
* caller's state. The pager is local UI state and is re-keyed when [mode]
* changes (so the new origin date can be picked up safely).
*
* @param selectedDate Currently selected day. Defaults to the only highlight
* used by [isSelectedOverride]'s default impl. Tapping the topbar pill jumps
* here.
* @param today Used for the "today" outline ring; also the date the topbar
* jumps to when tapped.
* @param mode Whether to render week strips or month grids.
* @param onSelectDate Called when the user taps a day cell.
* @param onModeChange Called when the user taps the expand chevron.
* @param onVisibleAnchorChange Called when the user swipes to a new period.
* Receives an anchor inside the now-visible period. The caller usually
* updates [selectedDate] in response (see PlannerViewModel for the pattern).
* @param dayState Per-day visual modifiers (dimmed for adjacent-month days is
* added automatically by the month grid).
* @param isSelectedOverride Custom selection predicate. Pass for range
* selection; defaults to `date == selectedDate`.
* @param expandable When true, renders the chevron and supports mode toggle.
* Popup variants (pantry/shopping) set this to false.
*/
@Composable
fun SwipeableCalendar(
selectedDate: LocalDate,
today: LocalDate,
mode: CalendarMode,
onSelectDate: (LocalDate) -> Unit,
onModeChange: (CalendarMode) -> Unit,
onVisibleAnchorChange: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
dayState: (LocalDate) -> DayState = { DayState() },
isSelectedOverride: ((LocalDate) -> Boolean)? = null,
expandable: Boolean = true,
locale: CalendarLocale = CalendarLocale.PL,
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp),
) {
val isSelected: (LocalDate) -> Boolean =
isSelectedOverride ?: { it == selectedDate }
val currentOnAnchorChange by rememberUpdatedState(onVisibleAnchorChange)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
// Re-key the pager block on mode so we can pick a fresh origin from
// the currently-selected date. The pager state is local; the caller
// never needs to scroll it manually.
key(mode) {
val origin = remember { selectedDate }
val initialPage = remember { INITIAL_PAGE }
val pagerState = rememberPagerState(initialPage = initialPage) { PAGE_COUNT }
CalendarTopbar(
mode = mode,
anchor = origin.plusPeriods(pagerState.currentPage - initialPage, mode),
today = today,
selectedDate = selectedDate,
locale = locale,
onJumpToToday = { onSelectDate(today) },
expandable = expandable,
onToggleMode = {
onModeChange(
if (mode == CalendarMode.Month) CalendarMode.Week else CalendarMode.Month,
)
},
modifier = Modifier.padding(contentPadding),
)
// Bring the pager onto the page that contains [selectedDate]
// whenever it changes externally (e.g., tap "today" on the topbar
// or a fresh selection from the page we're already on).
LaunchedEffect(selectedDate) {
val target = initialPage + periodsBetween(origin, selectedDate, mode)
if (target != pagerState.currentPage) {
pagerState.animateScrollToPage(target)
}
}
// Report swipe-driven anchor changes upward so the caller can keep
// its own selection in sync (e.g., planner auto-follows the week).
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }
.distinctUntilChanged()
.collect { page ->
if (page == initialPage) return@collect
val anchor = origin.plusPeriods(page - initialPage, mode)
if (!isInVisiblePeriod(selectedDate, anchor, mode)) {
currentOnAnchorChange(anchor)
}
}
}
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
WeekdayHeader(locale = locale)
HorizontalPager(
state = pagerState,
pageSpacing = 0.dp,
flingBehavior =
PagerDefaults.flingBehavior(
state = pagerState,
),
modifier = Modifier.fillMaxWidth(),
) { page ->
val pageAnchor = origin.plusPeriods(page - initialPage, mode)
Box(modifier = Modifier.fillMaxWidth()) {
when (mode) {
CalendarMode.Week -> {
WeekStrip(
anchor = pageAnchor,
today = today,
dayState = dayState,
isSelected = isSelected,
onSelect = onSelectDate,
)
}
CalendarMode.Month -> {
MonthGrid(
anchor = pageAnchor,
today = today,
dayState = dayState,
isSelected = isSelected,
onSelect = onSelectDate,
)
}
}
}
}
}
}
}
}
// Centered start lets the pager scroll forward and backward freely while
// keeping page indices small enough for the underlying lazy list. 100k pages
// in either direction is ~1900 years — far beyond any reasonable navigation.
private const val PAGE_COUNT: Int = 200_000
private const val INITIAL_PAGE: Int = PAGE_COUNT / 2

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,461 +0,0 @@
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.IndicationNodeFactory
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledIcon
import com.composeunstyled.UnstyledTab
import com.composeunstyled.UnstyledTabGroup
import com.composeunstyled.UnstyledTabList
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_close_a11y
import kotlin.math.roundToInt
/**
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
*
* Two structurally distinct shapes:
* - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs.
* Icon + label always shown (D-02); the sliding pill follows the active
* tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface]
* with `height / 2` corner radius.
* - **Collapsed** (`collapsed=true`): a single circular [CircleGlassButton]
* showing the active tab's icon. Tapping invokes [onCollapsedTap] (closes
* search per D-05).
*
* The two shapes are NOT animated between in-place — AppShell already
* cross-fades the expanded and collapsed instances via its own [AnimatedContent]
* when search opens / closes.
*
* ## Why the substrate is a *sibling* of the pill (not a parent)
*
* The pressed-tab affordance ([ExpandedDockTabs]) scales the pill up to 1.20×.
* For the pill to visibly extend *past* the dock's rounded contours, it cannot
* live inside the dock's [GlassSurface], whose `Modifier.clip(shape)` would
* crop it back to the rounded rect. So we wrap both in a no-clip [Box] and
* draw the pill as a sibling on top of the substrate — that's also why the
* substrate's `content` block is empty here.
*
* Substrate: only the shared [GlassSurface] / [CircleGlassButton] are used —
* direct Liquid API calls are forbidden here per CLAUDE.md non-negotiable #10.
*
* Touch targets: each tab cell + collapsed toggle is ≥ 44 dp (UI-SPEC line 52, 224).
*/
@Composable
fun DockBar(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
collapsed: Boolean,
onTabSelect: (BottomBarDestination) -> Unit,
onCollapsedTap: () -> Unit,
modifier: Modifier = Modifier,
height: Dp = 56.dp,
) {
if (collapsed) {
CircleGlassButton(
onClick = onCollapsedTap,
icon = active.icon,
contentDescription = stringResource(Res.string.search_close_a11y),
modifier = modifier,
size = height,
iconTint = RecipeTheme.colors.accent,
)
} else {
// Outer Box — no clip — hosts the dock substrate AND the tabs+pill
// layer so the pressed pill can scale (1.20×) past the dock contours.
Box(modifier = modifier.height(height)) {
// Substrate. Border is suppressed here so we can re-draw it on
// TOP of the pill at the end of the stack — that way the dock's
// outline stays visible through the (inner) pill GlassSurface,
// especially when the pill is pressed and scales past the dock.
GlassSurface(
modifier = Modifier.fillMaxSize(),
cornerRadius = height / 2,
border = null,
) {
// Empty: the actual pill + tabs live in the sibling overlay
// below, outside this GlassSurface's content clip.
}
ExpandedDockTabs(
destinations = destinations,
active = active,
dockHeight = height,
onTabSelect = onTabSelect,
)
// Top-z dock outline so the substrate's contour reads even where
// the pill overlaps it. Pure hairline (no fill) — purely a draw
// marker; doesn't intercept input.
Box(
modifier =
Modifier
.fillMaxSize()
.border(
BorderStroke(1.dp, RecipeTheme.colors.borderCard),
RoundedCornerShape(height / 2),
),
)
}
}
}
/**
* Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so
* we can drive a `Modifier.offset { IntOffset(...) }` without re-converting
* each frame.
*/
private data class TabBounds(
val offsetXPx: Float,
val widthPx: Float,
)
@Composable
private fun ExpandedDockTabs(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
dockHeight: Dp,
onTabSelect: (BottomBarDestination) -> Unit,
) {
val density = LocalDensity.current
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
// One [MutableInteractionSource] per tab so the pill can react to whichever
// tab the finger is *currently* down on — not just the active one.
val interactionSources =
remember(destinations) {
destinations.associateWith { MutableInteractionSource() }
}
// Subscribe to each tab's press state. `forEach` is inline, so the
// @Composable scope of this function propagates into the loop body and
// `collectIsPressedAsState` is a legal call here. `pressedTab` is a plain
// local recomputed per recomposition (cheap; only 4 tabs).
var pressedTab: BottomBarDestination? = null
destinations.forEach { dest ->
val pressed by interactionSources.getValue(dest).collectIsPressedAsState()
if (pressed) pressedTab = dest
}
// The pill follows whichever tab the finger is on; it settles back to
// the active tab once the press ends (with no click) OR onSelected has
// already updated `active` to match (with a click).
val pillTargetTab = pressedTab ?: active
// Pill is rendered wider than the cell so the indicator visually
// dominates without resizing any other cell. The pill bleeds into the
// 2 dp inter-cell gap and slightly into adjacent cells; icons + labels
// remain on top (z-order), readable above the dark substrate.
val pillExpansion = 8.dp
val pillExpansionPx = with(density) { pillExpansion.toPx() }
val pillX = remember { Animatable(0f) }
val pillW = remember { Animatable(0f) }
val pillScale = remember { Animatable(1f) }
var initialized by remember { mutableStateOf(false) }
// Drives the pill's tint: while either is true the pill stays translucent
// ("glass"); once both go false the pill settles to an opaque resting
// tint. `isPressActive` covers the user holding a finger down; the two
// `isXxxAnimating` flags cover the X/W slide and the scale-back-down so
// the pill stays glassy until the animations have fully settled.
var isXWAnimating by remember { mutableStateOf(false) }
var isScaleAnimating by remember { mutableStateOf(false) }
// First measurement: snap pill to the active cell so cold paint is correct.
LaunchedEffect(tabPositions[pillTargetTab]) {
if (initialized) return@LaunchedEffect
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
pillX.snapTo(t.offsetXPx - pillExpansionPx)
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
initialized = true
}
// Every subsequent change to the *target* tab — whether triggered by a tap
// (active changes) or by a press-down on an inactive tab (pressedTab
// changes) — animates the pill across in a single 200 ms tween. Cells are
// uniform-weight so the bounds captured here stay valid for the full
// animation; nothing moves under the pill mid-flight.
LaunchedEffect(pillTargetTab) {
if (!initialized) return@LaunchedEffect
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
isXWAnimating = true
try {
coroutineScope {
launch {
pillX.animateTo(
targetValue = t.offsetXPx - pillExpansionPx,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
)
}
launch {
pillW.animateTo(
targetValue = t.widthPx + 2f * pillExpansionPx,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
)
}
}
} finally {
isXWAnimating = false
}
}
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
// FastOutSlowInEasing so all chrome interactions read uniformly.
//
// - Scale 1.0 → 1.35: the pill "lifts" past the dock's outer rounded
// contours. The rest pill sits at a 4 dp vertical inset (visual height
// = dockHeight 8 dp). 1.35× grows it by ~10 dp on each side from its
// centre, which leaves ~6 dp sticking out above and below the dock —
// clearly past the substrate, not hugging the edge.
// - Same uniform factor on width preserves the rest pill's shape (a
// full capsule, cornerRadius = height/2 scales with the rest of the
// rect, so the scaled pill is *the same shape, just bigger*).
val isPressActive = pressedTab != null
LaunchedEffect(isPressActive) {
isScaleAnimating = true
try {
pillScale.animateTo(
targetValue = if (isPressActive) 1.35f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
)
} finally {
isScaleAnimating = false
}
}
// Pill is "busy" (and therefore stays glassy) while the user is holding
// it OR while it's still animating in any axis. Once everything settles,
// it crossfades to an opaque resting tint so the active tab reads as a
// clear solid pill rather than a translucent ghost.
val isPillBusy = isPressActive || isXWAnimating || isScaleAnimating
val pillBusyTint = Color.White.copy(alpha = 0.18f)
val pillRestingTint = Color(0xFF44474B)
val pillTint by animateColorAsState(
targetValue = if (isPillBusy) pillBusyTint else pillRestingTint,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill tint",
)
// Border only reads while the pill is glassy — when the pill settles to
// the opaque resting tint it becomes a solid plate and a hairline would
// just compete with the dock's outer outline. Animate the stroke's alpha
// so the border crossfades in/out together with the tint.
val pillBorderTarget = RecipeTheme.colors.borderCard
val pillBorderColor by animateColorAsState(
targetValue = if (isPillBusy) pillBorderTarget else pillBorderTarget.copy(alpha = 0f),
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill border",
)
// Liquid's `edge` rim is rendered even when the tint is fully opaque (the
// lens itself is nullified, but rim lighting still draws). Zero it out in
// the resting state — otherwise the pill keeps a visible bright outline
// even when we wanted a clean solid plate.
val pillEdge by animateFloatAsState(
targetValue = if (isPillBusy) 0.05f else 0f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill edge",
)
// Pill's resting visual height after the 4 dp inset on all sides.
val pillCorner = (dockHeight - 8.dp) / 2
Box(
modifier =
Modifier
.fillMaxSize()
// sm (8 dp) inner padding gives the pill room to expand up to
// 8 dp past its cell while still leaving the matching 4 dp gap
// to the dock's outer rounded edge on first / last tabs.
.padding(horizontal = RecipeTheme.spacing.sm),
) {
if (initialized) {
// The pill itself — a [GlassSurface] so the press-state can morph
// from "dark dim" to "glass" by tint alone, smoothly. Drawn FIRST
// so the tab list renders on top; .scale() at the end of the chain
// grows the pill (including its rounded clip) past the laid-out
// bounds with no parent clip to crop it.
GlassSurface(
modifier =
Modifier
.offset { IntOffset(pillX.value.roundToInt(), 0) }
.width(with(density) { pillW.value.toDp() })
.fillMaxHeight()
.padding(4.dp)
.scale(pillScale.value),
cornerRadius = pillCorner,
tint = pillTint,
border = BorderStroke(1.dp, pillBorderColor),
edgeIntensity = pillEdge,
) {}
}
// Tab row on top — icons + labels are drawn over the pill so the
// active tab's foreground (accent) reads against the dark inset, and
// the press-glass tint never obscures the pressed cell's icon.
//
// [NoIndication] override: `UnstyledTab`'s `indication` parameter is
// non-nullable (unlike `UnstyledButton`'s), so we can't pass `null` to
// suppress the platform state-layer / ripple. The pill IS our press
// indication; without this override the platform ripple draws inside
// the tab cell *under* the scaled glass pill, reading as a stray dark
// tint bleeding through.
CompositionLocalProvider(LocalIndication provides NoIndication) {
UnstyledTabGroup(
selectedTab = active.name,
tabs = destinations.map { it.name },
modifier = Modifier.fillMaxSize(),
) {
UnstyledTabList(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { dest ->
DockTabCell(
destination = dest,
isActive = dest == active,
interactionSource = interactionSources.getValue(dest),
onClick = { onTabSelect(dest) },
// Uniform weight — cells stay fixed during a tab
// switch. The "active feels bigger" emphasis is
// carried by the pill (size + tint), not by
// resizing the cell.
modifier =
Modifier
.weight(1f)
.onGloballyPositioned { coords ->
tabPositions[dest] =
TabBounds(
offsetXPx = coords.positionInParent().x,
widthPx = coords.size.width.toFloat(),
)
},
)
}
}
}
}
}
}
/**
* No-op [IndicationNodeFactory]. Provided in place of [LocalIndication.current]
* around the dock tab list so [UnstyledTab]'s `Modifier.selectable` doesn't
* paint a platform state-layer / ripple inside the cell — that would draw
* *under* the scaled-up glass pill and read as a stray tint bleeding through.
*
* The pill (size + glass tint) IS the press affordance; nothing else needed.
*/
private object NoIndication : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode = object : Modifier.Node() {}
override fun hashCode(): Int = 0
override fun equals(other: Any?): Boolean = other === this
}
@Composable
private fun DockTabCell(
destination: BottomBarDestination,
isActive: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Both states are fully opaque (alpha 1.0) — chrome foreground must not
// visually compete with the glass tafla underneath. `contentMuted` reads
// as transparent over translucent glass, so we use `content` for inactive
// tabs and rely on `accent` (saturated) to call out the active one.
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.content
val labelText = stringResource(destination.labelRes)
val a11ySuffix = if (isActive) ", aktywna" else ""
UnstyledTab(
key = destination.name,
selected = isActive,
onSelected = onClick,
activateOnFocus = false,
interactionSource = interactionSource,
shape = RoundedCornerShape(50),
backgroundColor = Color.Transparent,
contentPadding = PaddingValues(0.dp),
modifier =
modifier
.fillMaxSize()
.semantics {
contentDescription = labelText + a11ySuffix
},
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
UnstyledIcon(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.size(2.dp))
BasicText(
text = labelText,
style = RecipeTheme.typography.label.copy(color = tint),
)
}
}
}
}

View File

@@ -1,6 +1,5 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -22,24 +21,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Circular Liquid-glass icon button with iOS-style press feedback.
*
* Visual behaviour on press:
* - Scale 1.0 → 1.15 (whole button briefly grows under the finger).
* - Substrate tint shifts from [RecipeTheme.colors.surfaceGlass] to a
* translucent white overlay, so the button reads "lit up".
*
* Both transitions use the same 120 ms tween with [FastOutSlowInEasing], so
* the scale and tint move together. Compose's default [androidx.compose.foundation.Indication]
* (ripple / state-layer) is suppressed (`indication = null`) — this scale +
* tint pair is the project's standard press affordance for circular chrome.
*
* Used by the dock's floating search button, the search overlay's dismiss
* button, and any future round glass action in the chrome family.
*/
@Composable
fun CircleGlassButton(
onClick: () -> Unit,
@@ -49,21 +33,16 @@ fun CircleGlassButton(
size: Dp = 48.dp,
iconSize: Dp = 24.dp,
iconTint: Color = RecipeTheme.colors.content,
glassStyle: RecipeGlassStyle = RecipeTheme.glass.dock,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressedTint = Color.White.copy(alpha = 0.18f)
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.15f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "CircleGlassButton scale",
)
val tint by animateColorAsState(
targetValue = if (isPressed) pressedTint else RecipeTheme.colors.surfaceGlass,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "CircleGlassButton tint",
)
GlassSurface(
modifier =
@@ -71,7 +50,7 @@ fun CircleGlassButton(
.scale(scale)
.size(size),
cornerRadius = size / 2,
tint = tint,
glassStyle = glassStyle,
) {
UnstyledButton(
onClick = onClick,

View File

@@ -3,48 +3,41 @@ package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.rememberLiquidState
val LocalGlassBackdropState =
staticCompositionLocalOf<GlassBackdropState> {
error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
}
/**
* Shared source/sampling state for glass chrome.
*
* AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
* consume [LocalGlassBackdropState] so Liquid sample the same layer behind
* the dock/search chrome.
*/
@Stable
class GlassBackdropState internal constructor(
internal val liquidState: Any,
internal val liquidState: LiquidState,
)
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
@Composable
fun rememberGlassBackdropState(): GlassBackdropState {
val liquidState = rememberLiquidBackdropHandle()
val liquidState = rememberLiquidState()
return remember(liquidState) {
GlassBackdropState(
liquidState = liquidState,
)
GlassBackdropState(liquidState)
}
}
@Composable
fun GlassBackdropSource(
state: GlassBackdropState,
modifier: Modifier = Modifier,
state: GlassBackdropState = rememberGlassBackdropState(),
content: @Composable BoxScope.() -> Unit,
) {
CompositionLocalProvider(LocalGlassBackdropState provides state) {
Box(
modifier =
modifier
.liquidBackdropSource(state),
modifier = modifier
.liquefiable(state.liquidState),
content = content,
)
}
}

View File

@@ -1,23 +1,53 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.clip
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.RecipeTheme
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.liquid
/**
* @param recordAsSource Also register this surface as a Liquid source so other
* [GlassSurface]s sampling the same backdrop see this surface's refracted
* output — needed for nested glass-on-glass (e.g. a press overlay over the
* dock substrate). Liquid's ancestor-exclusion prevents this surface from
* sampling itself; outside its bounds it contributes nothing, so siblings
* that extend past the source's edges fall back to the shell backdrop
* seamlessly.
*/
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
edgeIntensity: Float = 0.05f,
glassStyle: RecipeGlassStyle = RecipeTheme.glass.dock,
recordAsSource: Boolean = false,
content: @Composable BoxScope.() -> Unit,
) {
val backdropState = LocalGlassBackdropState.current
LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, edgeIntensity, content)
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.then(if (recordAsSource) Modifier.liquefiable(backdropState.liquidState) else Modifier)
.liquid(backdropState.liquidState) {
refraction = glassStyle.refraction
curve = glassStyle.curve
edge = glassStyle.edge
dispersion = glassStyle.dispersion
saturation = glassStyle.saturation
contrast = glassStyle.contrast
frost = glassStyle.frost
this.shape = shape
glassStyle.tint?.let { this.tint = it }
},
content = content,
)
}

View File

@@ -25,33 +25,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Pill-shaped Liquid-glass text input with iOS-style press feedback.
*
* Visual behaviour on press:
* - Scale 1.0 → 1.05 (subtle — the pill is wide, so even small percentages
* are readable). Tween 120 ms `FastOutSlowInEasing`, matching the project's
* standard chrome-interaction timing.
* - **No** tint change — the keyboard appearing is its own colour event, so
* additional brightness on the field would compete.
*
* Press detection uses `Modifier.pointerInput` with `awaitEachGesture`, but
* never *consumes* the down event — the wrapped [BasicTextField] still
* receives the tap and handles focus / IME naturally. The scale animation
* runs concurrently with the focus request, so the user sees the pill bounce
* up the moment they touch it, while the keyboard slides into place.
*
* Reusable for any glass-style text input. [leadingContent] is a `null`-able
* slot for a leading icon or other affordance; if null, the field starts at
* the pill's leading edge.
*/
@Composable
fun GlassTextField(
value: String,
@@ -122,10 +104,7 @@ fun GlassTextField(
if (value.isEmpty()) {
BasicText(
text = placeholder,
style =
RecipeTheme.typography.body.copy(
color = Color.White,
),
style = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
)
}
innerField()

View File

@@ -1,59 +0,0 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.liquid
import io.github.fletchmckee.liquid.rememberLiquidState
/**
* Liquid backend per CONTEXT D-16. The source layer is applied by
* [GlassBackdropSource] through [liquidBackdropSource], and chrome consumes the
* same [LiquidState] here.
*/
@Composable
internal fun LiquidGlassSurface(
modifier: Modifier,
tint: Color,
cornerRadius: Dp,
border: BorderStroke?,
backdropState: GlassBackdropState?,
edgeIntensity: Float,
content: @Composable BoxScope.() -> Unit,
) {
val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState()
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.liquid(state) {
refraction = 0.10f
curve = 0.5f
edge = edgeIntensity
dispersion = 0.05f
saturation = 0.5f
contrast = 1.5f
frost = 10.dp
this.shape = shape
this.tint = tint
}
.let { if (border != null) it.border(border, shape) else it },
content = content,
)
}
@Composable
internal fun rememberLiquidBackdropHandle(): Any = rememberLiquidState()
internal fun Modifier.liquidBackdropSource(state: GlassBackdropState): Modifier = liquefiable(state.liquidState as LiquidState)

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

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.screens.recipes
package dev.ulfrx.recipe.ui.screens.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -14,24 +14,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_recipes_subtitle
import recipe.composeapp.generated.resources.empty_recipes_title
import recipe.composeapp.generated.resources.shell_tab_recipes
import recipe.composeapp.generated.resources.empty_home_subtitle
import recipe.composeapp.generated.resources.empty_home_title
import recipe.composeapp.generated.resources.shell_tab_home
/**
* Phase 2.1 empty-state screen for the Recipes tab. Phase 5 replaces the
* empty body with the recipe catalog grid.
*
* Search is now shell-wide (see `AppShell` + `ShellSearchViewModel`) this
* screen no longer owns any bottom-chrome state.
*/
@Composable
fun RecipesScreen(viewModel: RecipesViewModel) {
fun HomeScreen(viewModel: HomeViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -47,15 +40,15 @@ fun RecipesScreen(viewModel: RecipesViewModel) {
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_recipes),
text = stringResource(Res.string.shell_tab_home),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Recipes.icon,
title = stringResource(Res.string.empty_recipes_title),
subtitle = stringResource(Res.string.empty_recipes_subtitle),
icon = DockDestination.Home.icon,
title = stringResource(Res.string.empty_home_title),
subtitle = stringResource(Res.string.empty_home_subtitle),
)
}
}

View File

@@ -0,0 +1,15 @@
package dev.ulfrx.recipe.ui.screens.home
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class HomeState(
val isEmpty: Boolean = true,
)
class HomeViewModel : ViewModel() {
private val _state = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state.asStateFlow()
}

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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
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 org.jetbrains.compose.resources.stringResource
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.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
fun PantryScreen(viewModel: PantryViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
val today = remember { todayInSystemTz() }
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
BottomOverlayScaffold(
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(
modifier =
@@ -52,7 +60,7 @@ fun PantryScreen(viewModel: PantryViewModel) {
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Pantry.icon,
icon = DockDestination.Pantry.icon,
title = stringResource(Res.string.empty_pantry_title),
subtitle = stringResource(Res.string.empty_pantry_subtitle),
)

View File

@@ -1,19 +1,8 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
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,
)
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
class PantryViewModel : ViewModel() {
private val _state = MutableStateFlow(PantryState())
val state: StateFlow<PantryState> = _state.asStateFlow()
val horizon = HorizonCalendarHolder()
}

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,197 +1,67 @@
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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.DockDestination
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 org.jetbrains.compose.resources.stringResource
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
/**
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
* empty body with the calendar grid.
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable
fun PlannerScreen(viewModel: PlannerViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
val bgDark = Color(0xFF14181F)
val titleColor = Color(0xFFE8E4DC)
Box(
modifier =
Modifier
.fillMaxSize()
.background(bgDark),
BottomOverlayScaffold(
open = state.isCalendarOpen,
onDismiss = viewModel::closeCalendar,
bottomInset = rememberShellChromeHeight(),
overlay = {
PlannerCalendarPill(
selectedDate = state.selectedDate,
expanded = state.isCalendarOpen,
onExpandedChange = viewModel::setCalendarOpen,
onSelectDate = viewModel::selectDate,
onShiftSelection = viewModel::shiftSelection,
)
},
) {
// Scrollable, visually rich content sitting behind the glass chrome.
// Bottom contentPadding extends well past the dock so items keep
// scrolling under it (the whole point of this test view).
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
contentPadding =
PaddingValues(
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
top = RecipeTheme.spacing.xl + 48.dp,
bottom = 160.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = GlassTestItems, key = { it.id }) { item ->
GlassTestCard(item = item)
}
}
// Title pinned at the top so the chrome glass doesn't have to refract
// over the very top of the scrollable list.
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style = RecipeTheme.typography.title.copy(color = titleColor),
modifier =
Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(
top = RecipeTheme.spacing.xl,
start = RecipeTheme.spacing.lg,
),
)
}
}
private data class GlassTestItem(
val id: Int,
val accent: Color,
val cardTone: Color,
val titleWeight: Float,
val subtitleWeight: Float,
)
private val GlassTestItems: List<GlassTestItem> =
run {
val accents =
listOf(
Color(0xFFD97757), // accent terracotta
Color(0xFF6EA987), // sage
Color(0xFF7A8FB8), // dusty blue
Color(0xFFC1864F), // amber
Color(0xFFB76E79), // muted rose
Color(0xFF6B7A8F), // slate
Color(0xFF8E7CC3), // muted violet
)
val tones =
listOf(
Color(0xFF1F242C),
Color(0xFF232932),
Color(0xFF1B2028),
Color(0xFF272D36),
)
List(40) { i ->
GlassTestItem(
id = i,
accent = accents[i % accents.size],
cardTone = tones[i % tones.size],
titleWeight = 0.80f + ((i * 13) % 20) / 100f,
subtitleWeight = 0.55f + ((i * 7) % 40) / 100f,
)
}
}
@Composable
private fun GlassTestCard(item: GlassTestItem) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(88.dp)
.clip(RoundedCornerShape(16.dp))
.background(item.cardTone),
) {
// Left accent stripe — varied saturated colors so the dock chrome
// gets to refract a clear hue band as you scroll past.
Box(
modifier =
Modifier
.width(6.dp)
.fillMaxSize()
.background(item.accent),
)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
start = 12.dp + 6.dp,
end = 12.dp,
top = 12.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
// Title bar
Box(
modifier =
Modifier
.fillMaxWidth(item.titleWeight)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
// Subtitle bar
Box(
modifier =
Modifier
.fillMaxWidth(item.subtitleWeight)
.height(10.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
)
Spacer(modifier = Modifier.height(2.dp))
// Faint metadata dot + bar
Box(
modifier =
Modifier
.fillMaxWidth(0.18f)
.height(8.dp)
.clip(RoundedCornerShape(2.dp))
.background(item.accent.copy(alpha = 0.55f)),
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = DockDestination.Planner.icon,
title = stringResource(Res.string.empty_planner_title),
subtitle = stringResource(Res.string.empty_planner_subtitle),
)
}
Box(
modifier =
Modifier
.size(20.dp)
.padding(end = 0.dp),
)
}
}
}

View File

@@ -1,19 +1,40 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
/**
* UI state for [PlannerScreen]. Phase 2.1 ships only the empty state. Phase 6
* (Meal Planner — Core Write Path) extends this with calendar data + actions.
*/
data class PlannerState(
val isEmpty: Boolean = true,
val selectedDate: LocalDate,
val isCalendarOpen: Boolean = false,
)
class PlannerViewModel : ViewModel() {
private val _state = MutableStateFlow(PlannerState())
private val _state = MutableStateFlow(PlannerState(selectedDate = todayInSystemTz()))
val state: StateFlow<PlannerState> = _state.asStateFlow()
fun selectDate(date: LocalDate) {
_state.update { it.copy(selectedDate = date) }
}
/**
* Move the highlighted day without collapsing the calendar pill. Used by
* the collapsed strip's week-paged swipe gesture so swipe-to-shift doesn't
* also dismiss the calendar.
*/
fun shiftSelection(date: LocalDate) {
_state.update { it.copy(selectedDate = date) }
}
fun setCalendarOpen(open: Boolean) {
_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

@@ -1,19 +0,0 @@
package dev.ulfrx.recipe.ui.screens.recipes
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [RecipesScreen]. Phase 2.1 ships only the empty state. Phase 5
* (Recipe Catalog Read Path) extends this with `recipes` etc.
*/
data class RecipesState(
val isEmpty: Boolean = true,
)
class RecipesViewModel : ViewModel() {
private val _state = MutableStateFlow(RecipesState())
val state: StateFlow<RecipesState> = _state.asStateFlow()
}

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

@@ -1,94 +1,49 @@
package dev.ulfrx.recipe.ui.screens.search
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.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.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
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.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 org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
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_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
fun SearchScreen(viewModel: ShellSearchViewModel) {
fun SearchScreen(
viewModel: ShellSearchViewModel,
catalogGridState: LazyGridState,
) {
val catalogViewModel: RecipeCatalogViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val bgDark = Color(0xFF14181F)
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
val bottomSheetState = rememberRecipeBottomSheetState<Screen>()
Box(
modifier =
Modifier
.fillMaxSize()
.background(if (state.isFocused) bgDark else RecipeTheme.colors.background),
.background(RecipeTheme.colors.background),
) {
if (state.isFocused) {
// Sample search-result list — visual aid so the search pill / dock
// chrome has scrollable content underneath while wiring up real
// SearchSources lands in later phases. Remove once Phase 5/6/8/9
// back this screen with real results.
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
contentPadding =
PaddingValues(
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
top = RecipeTheme.spacing.xl,
bottom = 160.dp,
),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = SearchResultSamples, key = { it.id }) { item ->
SearchResultRow(item = item)
}
}
} else {
Box(
modifier =
Modifier
@@ -98,110 +53,19 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
) {
EmptyState(
icon = Lucide.Search,
title = stringResource(Res.string.search_screen_curated_title),
subtitle = stringResource(Res.string.search_screen_curated_subtitle),
)
}
}
}
}
private data class SearchResultSample(
val id: Int,
val avatarColor: Color,
val cardTone: Color,
val titleWeight: Float,
val subtitleWeight: Float,
val tagWeight: Float,
)
private val SearchResultSamples: List<SearchResultSample> =
run {
val avatars =
listOf(
Color(0xFFD97757), // terracotta
Color(0xFF6EA987), // sage
Color(0xFF7A8FB8), // dusty blue
Color(0xFFC1864F), // amber
Color(0xFFB76E79), // muted rose
Color(0xFF6B7A8F), // slate
Color(0xFF8E7CC3), // muted violet
Color(0xFFA89B7C), // olive
)
val tones =
listOf(
Color(0xFF1F242C),
Color(0xFF232932),
Color(0xFF1B2028),
Color(0xFF272D36),
)
List(36) { i ->
SearchResultSample(
id = i,
avatarColor = avatars[i % avatars.size],
cardTone = tones[i % tones.size],
titleWeight = 0.62f + ((i * 11) % 30) / 100f,
subtitleWeight = 0.40f + ((i * 7) % 35) / 100f,
tagWeight = 0.12f + ((i * 5) % 14) / 100f,
)
}
}
@Composable
private fun SearchResultRow(item: SearchResultSample) {
Row(
modifier =
Modifier
.fillMaxWidth()
.height(76.dp)
.clip(RoundedCornerShape(14.dp))
.background(item.cardTone)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Round avatar / thumbnail slot — gives each row a recognizable
// colored anchor that refracts cleanly through the search pill above.
Box(
modifier =
Modifier
.size(48.dp)
.clip(CircleShape)
.background(item.avatarColor),
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
// Title bar
Box(
modifier =
Modifier
.fillMaxWidth(item.titleWeight)
.height(13.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
)
// Subtitle bar
Box(
modifier =
Modifier
.fillMaxWidth(item.subtitleWeight)
.height(9.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Small accent tag pill
Box(
modifier =
Modifier
.fillMaxWidth(item.tagWeight)
.height(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(item.avatarColor.copy(alpha = 0.65f)),
title = stringResource(Res.string.search_screen_empty_results_title),
subtitle = stringResource(Res.string.search_screen_empty_results_subtitle),
)
}
} else {
RecipeCatalogGrid(
state = catalogState,
onRecipeClick = { bottomSheetState.open(Screen.RecipeDetail(it)) },
gridState = catalogGridState,
modifier = Modifier.fillMaxSize(),
)
}
RecipeSheet(state = bottomSheetState)
}
}

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe.ui.screens.search
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.search.SearchState
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

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

@@ -1,103 +1,55 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.RootNavDisplay
import dev.ulfrx.recipe.navigation.TabNavigator
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
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.components.search.SearchPillRow
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.ShellSearchViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_placeholder
/**
* Authenticated root composable. Owns:
* - the per-tab navigation back stacks via [TabNavigator]
* - the shell-wide search affordance via [ShellSearchViewModel]
*
* ## Body modes (driven by `searchVm.state.isOpen`)
*
* - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom
* chrome is `[DockBar (full)] [FloatingSearchButton]`.
* - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom
* chrome is [SearchPillRow], whose layout shifts further on `isFocused`
* (collapsed dock icon yields to a full-width pill + X — see SearchChrome.kt).
*
* ## Back-press handling
*
* While search is open, a [BackHandler] consumes the back press as a no-op:
* the user must exit search explicitly via the collapsed dock icon (B→A) or X
* (C→B). Confirmed product decision — no implicit dismissal while in search.
*
* ## Why TabNavigator and not the AndroidX NavController
* (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See
* [RootNavDisplay] for the full rationale.)
*/
@OptIn(ExperimentalComposeUiApi::class)
@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the
// latter is overkill for a static "consume back" guard. Revisit when stable.
@Preview
@Composable
fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
// Hoisted so both the body (liquefiable source) and the bottom chrome
// (liquid samplers) share a single LiquidState. Without this the chrome
// would fall back to a fresh, sourceless state and render as flat tint.
val backdropState = rememberGlassBackdropState()
val overlayDismisser = remember { OverlayDismisser() }
BackHandler(enabled = searchState.isOpen) {
// Blocked — user must exit search via explicit affordance (dock icon or X).
}
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
CompositionLocalProvider(
LocalGlassBackdropState provides backdropState,
LocalOverlayDismisser provides overlayDismisser,
) {
Box(
modifier =
modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
// Body — cross-fade between the tab stack and the search overlay.
GlassBackdropSource(
state = backdropState,
modifier = Modifier.fillMaxSize(),
@@ -112,7 +64,10 @@ fun AppShell(modifier: Modifier = Modifier) {
label = "AppShell body",
) { searchOpen ->
if (searchOpen) {
SearchScreen(viewModel = searchVm)
SearchScreen(
viewModel = searchVm,
catalogGridState = catalogGridState,
)
} else {
RootNavDisplay(
navigator = navigator,
@@ -122,115 +77,23 @@ fun AppShell(modifier: Modifier = Modifier) {
}
}
// Bottom chrome — Apple-Music-style: don't respect the full nav-bar
// inset (home indicator) for the bottom edge; halve it so chrome sits
// close to the bottom and the home indicator visually overlaps the
// chrome substrate. When IME is up, use the full IME inset (it's much
// larger than navInset/2, so `max` keeps the chrome above the keyboard).
val bottomInset =
with(LocalDensity.current) {
val imePx = WindowInsets.ime.getBottom(this)
val navPx = WindowInsets.navigationBars.getBottom(this)
maxOf(imePx, navPx / 2).toDp()
}
// Horizontal chrome padding animates with the search state:
// - Closed (dock visible) → xl (24 dp)
// - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly
// inset from the dock's footprint
// - Open, focused (search C) → 8 dp, so the input reads as a width
// extension of the keyboard above it
val horizontalPadding by animateDpAsState(
targetValue =
when {
!searchState.isOpen -> RecipeTheme.spacing.xl
!searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp
else -> 8.dp
},
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
label = "chrome horizontal padding",
)
Row(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(
start = horizontalPadding,
end = horizontalPadding,
top = RecipeTheme.spacing.sm,
bottom = bottomInset + RecipeTheme.spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(
targetState = searchState.isOpen,
// Lock chrome region to the dock's height in both modes so
// (a) the body above doesn't shift when search opens / closes,
// and (b) the (shorter) search pill is centred vertically
// inside the same band the dock occupies.
modifier = Modifier.fillMaxWidth().height(63.dp),
contentAlignment = Alignment.Center,
// Exit is instant (no fade-out): the outgoing chrome cell —
// dock OR search pill row — may still be playing its press
// animation (the user's finger triggered the tap that switched
// states). If we also fade it out, the half-faded pressed-up
// button overlaps visually with the incoming pill, which reads
// as "two things on screen at once". Instant exit makes the
// hand-off feel clean while the press animation keeps running
// off-screen on the now-removed branch.
transitionSpec = {
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
ExitTransition.None
},
label = "AppShell bottom chrome",
) { searchOpen ->
if (searchOpen) {
SearchPillRow(
query = searchState.query,
isFocused = searchState.isFocused,
placeholder = stringResource(Res.string.search_placeholder),
ShellBottomChrome(
activeTab = navigator.activeTab,
onTabSelect = { tab ->
overlayDismisser.dismissAll()
navigator.selectTab(tab)
},
search =
SearchHandlers(
state = searchState,
onOpen = searchVm::open,
onQueryChange = searchVm::onQueryChange,
onClose = searchVm::close,
onFocusGained = searchVm::focus,
onFocusLost = searchVm::unfocus,
onFocus = searchVm::focus,
onUnfocus = searchVm::unfocus,
),
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
DefaultDockRow(
activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab,
onSearchTap = searchVm::open,
)
}
}
}
}
}
}
@Composable
private fun DefaultDockRow(
activeTab: BottomBarDestination,
onTabSelect: (BottomBarDestination) -> Unit,
onSearchTap: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = false,
onTabSelect = onTabSelect,
onCollapsedTap = { /* unreachable in default mode */ },
modifier = Modifier.weight(1f),
height = 63.dp,
)
Box(modifier = Modifier.size(63.dp)) {
FloatingSearchButton(onClick = onSearchTap)
}
}
}

View File

@@ -0,0 +1,174 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.screens.shell.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.screens.shell.search.SearchPillRow
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_placeholder
/**
* Search-side inputs for [ShellBottomChrome] — the live [SearchState] plus the
* lambdas the chrome calls back into. Bundled into one holder so the chrome's
* parameter list doesn't grow with the VM, and so a `@Preview` can construct
* one with no-op lambdas to render any of the three states without a real VM.
*
* Data class on purpose: structural equality means Compose can skip-recompose
* the chrome when [AppShell] re-emits an identical handler bag (lambdas built
* from the same VM method references compare equal).
*/
data class SearchHandlers(
val state: SearchState,
val onOpen: () -> Unit,
val onQueryChange: (String) -> Unit,
val onClose: () -> Unit,
val onFocus: () -> Unit,
val onUnfocus: () -> Unit,
)
/**
* Bottom chrome for [AppShell]. Owns the dock ↔ search-pill swap and the
* three-state geometry choreography (insets, horizontal-padding curve, height
* lock, AnimatedContent transition tuning).
*
* Modes — driven by [search].state:
* - **A (closed)** — `[DockBar (full)] [FloatingSearchButton]`
* - **B (open, unfocused)** — `[collapsed dock icon] [search pill]`
* - **C (open, focused)** — `[search pill (full width)] [X button]`
*
* Geometry contract (kept here so [AppShell] doesn't need to know any of it):
* - The chrome band is height-locked to the dock's 63 dp so the body above
* doesn't shift when search opens/closes; the (shorter) search pill is
* centred vertically inside that band.
* - Horizontal padding animates with state (xl → xl+2 → 8 dp). The narrow C
* inset makes the focused input read as a width extension of the keyboard
* above it.
* - Bottom inset uses navBar/2 (Apple Music pattern — chrome sits close to
* the bottom and the home indicator visually overlaps the substrate). When
* the IME is up the IME inset wins via `max`.
*/
@Composable
fun ShellBottomChrome(
activeTab: DockDestination,
onTabSelect: (DockDestination) -> Unit,
search: SearchHandlers,
modifier: Modifier = Modifier,
) {
val bottomInset =
with(LocalDensity.current) {
val imePx = WindowInsets.ime.getBottom(this)
val navPx = WindowInsets.navigationBars.getBottom(this)
maxOf(imePx, navPx / 2).toDp()
}
val horizontalPadding by animateDpAsState(
targetValue =
when {
!search.state.isOpen -> RecipeTheme.spacing.xl
!search.state.isFocused -> RecipeTheme.spacing.xl + 2.dp
else -> 8.dp
},
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
label = "chrome horizontal padding",
)
Row(
modifier =
modifier
.fillMaxWidth()
.padding(
start = horizontalPadding,
end = horizontalPadding,
top = RecipeTheme.spacing.sm,
bottom = bottomInset + RecipeTheme.spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(
targetState = search.state.isOpen,
modifier = Modifier.fillMaxWidth().height(DockBandHeight),
contentAlignment = Alignment.Center,
// Exit is instant (no fade-out): the outgoing chrome cell — dock
// OR search pill row — may still be playing its press animation
// (the user's finger triggered the tap that switched states). If
// we also fade it out, the half-faded pressed-up button overlaps
// visually with the incoming pill, which reads as "two things on
// screen at once". Instant exit keeps the hand-off clean while
// the press animation finishes off-screen on the removed branch.
transitionSpec = {
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
ExitTransition.None
},
label = "AppShell bottom chrome",
) { searchOpen ->
if (searchOpen) {
SearchPillRow(
query = search.state.query,
isFocused = search.state.isFocused,
placeholder = stringResource(Res.string.search_placeholder),
activeTab = activeTab,
onQueryChange = search.onQueryChange,
onClose = search.onClose,
onFocusGained = search.onFocus,
onFocusLost = search.onUnfocus,
)
} else {
DockRow(
activeTab = activeTab,
onTabSelect = onTabSelect,
onSearchTap = search.onOpen,
)
}
}
}
}
@Composable
private fun DockRow(
activeTab: DockDestination,
onTabSelect: (DockDestination) -> Unit,
onSearchTap: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
DockBar(
destinations = DockDestination.entries,
active = activeTab,
collapsed = false,
onTabSelect = onTabSelect,
modifier = Modifier.weight(1f),
height = DockBandHeight,
)
Box(modifier = Modifier.size(DockBandHeight)) {
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

@@ -0,0 +1,139 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.DockDestination
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.dock_expand_a11y
@Composable
fun DockBar(
destinations: List<DockDestination>,
active: DockDestination,
collapsed: Boolean,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier = Modifier,
height: Dp = 56.dp,
) {
if (collapsed) {
DockBarCollapsed(
active = active,
onTabSelect = onTabSelect,
modifier = modifier,
height = height,
)
} else {
DockBarExpanded(
destinations = destinations,
active = active,
onTabSelect = onTabSelect,
modifier = modifier,
height = height,
)
}
}
@Composable
private fun DockBarCollapsed(
active: DockDestination,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier,
height: Dp,
) {
CircleGlassButton(
onClick = { onTabSelect(active) },
icon = active.icon,
contentDescription = stringResource(Res.string.dock_expand_a11y),
modifier = modifier,
size = height,
iconTint = RecipeTheme.colors.accent,
)
}
@Composable
private fun DockBarExpanded(
destinations: List<DockDestination>,
active: DockDestination,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier,
height: Dp,
) {
val tabBounds = remember { mutableStateMapOf<Int, TabBounds>() }
var pressState by remember { mutableStateOf<DockPressState>(DockPressState.Idle) }
var dockWidthPx by remember { mutableStateOf(0f) }
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
Box(
modifier =
modifier
.height(height)
.onSizeChanged { dockWidthPx = it.width.toFloat() }
.pointerInput(destinations) {
trackDockGesture { event ->
when (event) {
is DockPressEvent.Pressing -> {
pressState = DockPressState.Pressing(event.xPx)
}
is DockPressEvent.Released -> {
tabIndexAt(event.xPx, tabBounds)?.let { idx ->
onTabSelect(destinations[idx])
}
pressState = DockPressState.Idle
}
DockPressEvent.Cancelled -> {
pressState = DockPressState.Idle
}
}
}
},
) {
val anim =
rememberDockOverlayAnimations(
pressState = pressState,
activeIndex = activeIndex,
tabBounds = tabBounds,
dockWidthPx = dockWidthPx,
density = LocalDensity.current,
)
DockSubstrate(cornerRadius = height / 2)
DockActiveIndicatorLayer(
activeIndex = activeIndex,
tabBounds = tabBounds,
dockWidthPx = dockWidthPx,
alpha = anim.activeIndicatorAlpha,
)
DockPressOverlayLayer(
overlayCenterX = anim.overlayCenterX,
overlayWidthPx = anim.overlayWidthPx,
overlayAlpha = anim.overlayAlpha,
overlayPeakProgress = anim.overlayPeakProgress,
dockWidthPx = dockWidthPx,
dockHeight = height,
)
DockTabRow(
destinations = destinations,
activeIndex = activeIndex,
tabBounds = tabBounds,
dockWidthPx = dockWidthPx,
onTabSelectFromA11y = onTabSelect,
onTabBoundsChange = { index, bounds -> tabBounds[index] = bounds },
)
}
}

View File

@@ -0,0 +1,69 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
private val ActiveIndicatorBleed = 4.dp
private val ActiveIndicatorEdgeInset = 5.dp
internal data class TabBounds(
val offsetXPx: Float,
val widthPx: Float,
)
internal sealed interface DockPressState {
data object Idle : DockPressState
data class Pressing(
val xPx: Float,
) : DockPressState
}
internal sealed interface DockPressEvent {
data class Pressing(
val xPx: Float,
) : DockPressEvent
data class Released(
val xPx: Float,
) : DockPressEvent
data object Cancelled : DockPressEvent
}
internal data class ActiveIndicatorBbox(
val leftPx: Float,
val rightPx: Float,
) {
val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
val centerPx: Float get() = (leftPx + rightPx) / 2f
}
internal fun activeIndicatorBboxFor(
cell: TabBounds,
dockWidthPx: Float,
density: Density,
): ActiveIndicatorBbox {
val bleedPx = with(density) { ActiveIndicatorBleed.toPx() }
val edgeInsetPx = with(density) { ActiveIndicatorEdgeInset.toPx() }
val left = (cell.offsetXPx - bleedPx).coerceAtLeast(edgeInsetPx)
val right = (cell.offsetXPx + cell.widthPx + bleedPx).coerceAtMost(dockWidthPx - edgeInsetPx)
return ActiveIndicatorBbox(left, right)
}
internal fun tabIndexAt(
x: Float,
bounds: Map<Int, TabBounds>,
): Int? {
if (bounds.isEmpty()) return null
val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
var result = sorted.first().key
for (entry in sorted) {
if (entry.value.offsetXPx <= x) {
result = entry.key
} else {
break
}
}
return result
}

View File

@@ -0,0 +1,195 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Cancelled
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Released
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.abs
private val PressOverlayBleed = 4.dp
private const val SLIDE_OUTWARD_STIFFNESS = Spring.StiffnessMediumLow
private const val SLIDE_SETTLE_STIFFNESS = Spring.StiffnessHigh
private const val OVERLAY_FADE_IN_DURATION_MS = 120
private const val OVERLAY_FADE_OUT_DURATION_MS = 40
private const val SETTLE_EPSILON_PX = 0.5f
internal data class DockOverlayAnimations(
val overlayCenterX: Float,
val overlayWidthPx: Float,
val overlayAlpha: Float,
val overlayPeakProgress: Float,
val activeIndicatorAlpha: Float,
)
@Composable
internal fun rememberDockOverlayAnimations(
pressState: DockPressState,
activeIndex: Int,
tabBounds: Map<Int, TabBounds>,
dockWidthPx: Float,
density: Density,
): DockOverlayAnimations {
val activeBounds = tabBounds[activeIndex]
val activeCenterX = activeBounds?.let { it.offsetXPx + it.widthPx / 2f } ?: 0f
val bleedPx = with(density) { PressOverlayBleed.toPx() }
val overlayWidthPx = (activeBounds?.widthPx ?: 0f) + 2 * bleedPx
val centerXMin = overlayWidthPx / 2f
val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin)
val pressingXPx = (pressState as? DockPressState.Pressing)?.xPx
val clampedPressX = pressingXPx?.coerceIn(centerXMin, centerXMax)
val centerAnim = remember { Animatable(activeCenterX) }
val overlayAlphaAnim = remember { Animatable(0f) }
val activeAlphaAnim = remember { Animatable(1f) }
var wasPressed by remember { mutableStateOf(false) }
LaunchedEffect(clampedPressX, activeCenterX) {
when {
clampedPressX == null -> {
wasPressed = false
centerAnim.animateTo(
activeCenterX,
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SLIDE_SETTLE_STIFFNESS,
visibilityThreshold = SETTLE_EPSILON_PX,
),
)
}
!wasPressed -> {
wasPressed = true
centerAnim.animateTo(
clampedPressX,
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SLIDE_OUTWARD_STIFFNESS,
visibilityThreshold = SETTLE_EPSILON_PX,
),
)
}
else -> {
centerAnim.snapTo(clampedPressX)
}
}
}
val pressing = pressState is DockPressState.Pressing
val activeCenterXState = rememberUpdatedState(activeCenterX)
var releaseSlideStartX by remember { mutableStateOf<Float?>(null) }
LaunchedEffect(pressing) {
if (pressing) {
releaseSlideStartX = null
activeAlphaAnim.snapTo(0f)
overlayAlphaAnim.animateTo(
targetValue = 1f,
animationSpec = tween(OVERLAY_FADE_IN_DURATION_MS, easing = FastOutSlowInEasing),
)
} else {
if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect
releaseSlideStartX = centerAnim.value
if (overlayAlphaAnim.value < 1f) {
val tailMs =
((1f - overlayAlphaAnim.value) * OVERLAY_FADE_IN_DURATION_MS)
.toInt()
.coerceAtLeast(0)
if (tailMs > 0) {
overlayAlphaAnim.animateTo(1f, tween(tailMs, easing = FastOutSlowInEasing))
}
}
snapshotFlow {
!centerAnim.isRunning &&
abs(centerAnim.value - activeCenterXState.value) < SETTLE_EPSILON_PX
}.first { it }
coroutineScope {
launch {
overlayAlphaAnim.animateTo(
targetValue = 0f,
animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
)
}
launch {
activeAlphaAnim.animateTo(
targetValue = 1f,
animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
)
}
}
releaseSlideStartX = null
}
}
val releaseSlideProgress =
run {
val start = releaseSlideStartX
if (start == null) {
0f
} else {
val target = activeCenterXState.value
val total = abs(target - start)
if (total < 1f) {
0f
} else {
(abs(centerAnim.value - start) / total).coerceIn(0f, 1f)
}
}
}
val overlayPeakProgress = overlayAlphaAnim.value * (1f - releaseSlideProgress)
return DockOverlayAnimations(
overlayCenterX = centerAnim.value,
overlayWidthPx = overlayWidthPx,
overlayAlpha = overlayAlphaAnim.value,
overlayPeakProgress = overlayPeakProgress,
activeIndicatorAlpha = activeAlphaAnim.value,
)
}
internal suspend fun PointerInputScope.trackDockGesture(onPressEvent: (DockPressEvent) -> Unit) {
awaitEachGesture {
val pressDown = awaitFirstDown(requireUnconsumed = false)
pressDown.consume()
val pointerId = pressDown.id
onPressEvent(Pressing(pressDown.position.x))
while (true) {
val pointerEvent = awaitPointerEvent()
val pressChange = pointerEvent.changes.firstOrNull { it.id == pointerId }
if (pressChange == null) {
onPressEvent(Cancelled)
break
}
if (!pressChange.pressed) {
onPressEvent(Released(pressChange.position.x))
pressChange.consume()
break
}
if (pressChange.positionChanged()) {
onPressEvent(Pressing(pressChange.position.x))
}
pressChange.consume()
}
}
}

View File

@@ -0,0 +1,100 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
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.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
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.theme.RecipeTheme
import kotlin.math.roundToInt
private val PressOverlayVerticalInset = 0.dp
private val ActiveIndicatorVerticalInset = 5.dp
private const val PRESS_OVERLAY_SCALE = 1.22f
@Composable
internal fun DockSubstrate(cornerRadius: Dp) {
GlassSurface(
modifier = Modifier.fillMaxSize(),
cornerRadius = cornerRadius,
recordAsSource = true,
) {}
}
@Composable
internal fun DockActiveIndicatorLayer(
activeIndex: Int,
tabBounds: Map<Int, TabBounds>,
dockWidthPx: Float,
alpha: Float,
) {
val bounds = tabBounds[activeIndex] ?: return
if (alpha <= 0f || dockWidthPx <= 0f) return
val density = LocalDensity.current
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
Box(
modifier =
Modifier
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
.width(with(density) { bbox.widthPx.toDp() })
.fillMaxHeight()
.padding(vertical = ActiveIndicatorVerticalInset)
.alpha(alpha)
.background(
color = RecipeTheme.colors.chromeActive,
shape = RoundedCornerShape(50),
),
)
}
@Composable
internal fun DockPressOverlayLayer(
overlayCenterX: Float,
overlayWidthPx: Float,
overlayAlpha: Float,
overlayPeakProgress: Float,
dockWidthPx: Float,
dockHeight: Dp,
) {
if (overlayAlpha <= 0f || dockWidthPx <= 0f || overlayWidthPx <= 0f) return
val density = LocalDensity.current
val dockHeightPx = with(density) { dockHeight.toPx() }
val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() }
val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f
val scaleX = lerp(1f, PRESS_OVERLAY_SCALE, overlayPeakProgress)
val scaleY = lerp(activeStartScaleY, PRESS_OVERLAY_SCALE, overlayPeakProgress)
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
val leftPx = overlayCenterX - overlayWidthPx / 2f
GlassSurface(
modifier =
Modifier
.offset { IntOffset(leftPx.roundToInt(), 0) }
.width(with(density) { overlayWidthPx.toDp() })
.fillMaxHeight()
.padding(vertical = PressOverlayVerticalInset)
.graphicsLayer {
this.scaleX = scaleX
this.scaleY = scaleY
}.alpha(overlayAlpha),
cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress,
) {}
}

View File

@@ -0,0 +1,134 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
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.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
private val DockTabIconSize = 18.dp
private val DockTabIconLabelGap = 2.dp
private const val DOCK_TAB_LABEL_FONT_SIZE_SP = 11
private const val DOCK_TAB_LABEL_LINE_HEIGHT_SP = 13
@Composable
internal fun DockTabRow(
destinations: List<DockDestination>,
activeIndex: Int,
tabBounds: Map<Int, TabBounds>,
dockWidthPx: Float,
onTabSelectFromA11y: (DockDestination) -> Unit,
onTabBoundsChange: (Int, TabBounds) -> Unit,
) {
val density = LocalDensity.current
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEachIndexed { index, destination ->
val cellBounds = tabBounds[index]
val contentOffsetPx =
if (cellBounds != null && dockWidthPx > 0f) {
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
bbox.centerPx - cellCenterX
} else {
0f
}
DockTabItem(
destination = destination,
isActive = index == activeIndex,
contentOffsetPx = contentOffsetPx,
onSelect = { onTabSelectFromA11y(destination) },
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.onGloballyPositioned { coords ->
onTabBoundsChange(
index,
TabBounds(
offsetXPx = coords.positionInParent().x,
widthPx = coords.size.width.toFloat(),
),
)
},
)
}
}
}
@Composable
private fun DockTabItem(
destination: DockDestination,
isActive: Boolean,
contentOffsetPx: Float,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val label = stringResource(destination.labelRes)
val a11yLabel = if (isActive) "$label, aktywna" else label
val tint = if(isActive) RecipeTheme.colors.accent else RecipeTheme.colors.content
Box(
modifier =
modifier.semantics {
role = Role.Tab
selected = isActive
contentDescription = a11yLabel
onClick {
onSelect()
true
}
},
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.offset { IntOffset(contentOffsetPx.roundToInt(), 0) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
UnstyledIcon(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(DockTabIconSize),
)
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
BasicText(
text = label,
style =
RecipeTheme.typography.label.copy(
color = tint,
fontSize = DOCK_TAB_LABEL_FONT_SIZE_SP.sp,
lineHeight = DOCK_TAB_LABEL_LINE_HEIGHT_SP.sp,
),
)
}
}
}

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -6,6 +6,7 @@ import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_open_a11y

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
@@ -20,9 +20,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -53,7 +53,7 @@ fun SearchPillRow(
query: String,
isFocused: Boolean,
placeholder: String,
activeTab: BottomBarDestination,
activeTab: DockDestination,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
onFocusGained: () -> Unit,
@@ -98,11 +98,12 @@ fun SearchPillRow(
exit = sideButtonExit,
) {
DockBar(
destinations = BottomBarDestination.entries,
destinations = DockDestination.entries,
active = activeTab,
collapsed = true,
onTabSelect = { /* unreachable while collapsed */ },
onCollapsedTap = onClose,
// Collapsed dock only emits a re-select of the active tab,
// which here means "close the search overlay".
onTabSelect = { onClose() },
height = pillHeight,
)
}

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
/**
* Shell-wide search state shape, exposed by

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@@ -37,7 +37,7 @@ fun SearchPill(
UnstyledIcon(
imageVector = Lucide.Search,
contentDescription = null,
tint = RecipeTheme.colors.contentMuted,
tint = RecipeTheme.colors.content,
modifier = Modifier.size(20.dp),
)
},

View File

@@ -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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
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 org.jetbrains.compose.resources.stringResource
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.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
fun ShoppingScreen(viewModel: ShoppingViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
val today = remember { todayInSystemTz() }
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
BottomOverlayScaffold(
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(
modifier =
@@ -52,7 +60,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) {
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Shopping.icon,
icon = DockDestination.Shopping.icon,
title = stringResource(Res.string.empty_shopping_title),
subtitle = stringResource(Res.string.empty_shopping_subtitle),
)

View File

@@ -1,19 +1,8 @@
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
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,
)
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
class ShoppingViewModel : ViewModel() {
private val _state = MutableStateFlow(ShoppingState())
val state: StateFlow<ShoppingState> = _state.asStateFlow()
val horizon = HorizonCalendarHolder()
}

View File

@@ -2,10 +2,6 @@ package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Semantic color tokens (UI-SPEC § Color, CONTEXT D-14, D-15).
* Values are locked; do not introduce raw hex in screen code.
*/
public data class RecipeColors(
val background: Color,
val surface: Color,
@@ -13,33 +9,45 @@ public data class RecipeColors(
val content: Color,
val contentMuted: Color,
val accent: Color,
val chromeActive: Color,
val separator: Color,
val borderCard: Color,
val destructive: Color,
val macroProtein: Color,
val macroFat: Color,
val macroCarbs: Color,
)
public val LightRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFFF7F5F1),
background = Color(0xFFEAE6DF),
surface = Color(0xFFFFFFFF),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.6f),
content = Color(0xFF0F1113),
contentMuted = Color(0xFF6B6E73),
accent = Color(0xFFD97757),
chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f),
separator = Color(0xFFE5E1DA),
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
destructive = Color(0xFFC0392B),
macroProtein = Color(0xFF3B82F6),
macroFat = Color(0xFFD97706),
macroCarbs = Color(0xFFEA580C),
)
public val DarkRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFF0F1113),
surface = Color(0xFF1A1D21),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.18f),
background = Color(0xFF1E2024),
surface = Color(0xFF2A2D31),
surfaceGlass = Color(0xFF313439).copy(alpha = 0.65f),
content = Color(0xFFF1EFEA),
contentMuted = Color(0xFF9AA0A6),
accent = Color(0xFFE48A6E),
separator = Color(0xFF2A2D31),
accent = Color(0xFFFC8964),
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
separator = Color(0xFF383B40),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
destructive = Color(0xFFE57368),
macroProtein = Color(0xFF60A5FA),
macroFat = Color(0xFFFBBF24),
macroCarbs = Color(0xFFFB923C),
)

View File

@@ -1,28 +1,76 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
/**
* Glass surface defaults (UI-SPEC § Glass / Layout).
* Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
* floating button (plan 02.1-05).
*/
public data class RecipeGlass(
val borderWidth: Dp,
val shadowOffsetY: Dp,
val shadowBlur: Dp,
val shadowAlphaLight: Float,
val shadowAlphaDark: Float,
val blurRadius: Dp,
data class RecipeGlass(
val dock: RecipeGlassStyle,
val dockPress: RecipeGlassStyle,
val button: RecipeGlassStyle,
val panel: RecipeGlassStyle,
val chipOnGlass: RecipeGlassStyle,
)
public val DefaultRecipeGlass: RecipeGlass =
fun recipeGlassFor(colors: RecipeColors): RecipeGlass =
RecipeGlass(
borderWidth = 1.dp,
shadowOffsetY = 8.dp,
shadowBlur = 24.dp,
shadowAlphaLight = 0.12f,
shadowAlphaDark = 0.0f,
blurRadius = 24.dp,
dock = RecipeGlassStyle(
refraction = 0.5f,
curve = 0.4f,
edge = 0.03f,
dispersion = 0f,
saturation = 1f,
contrast = 1f,
frost = 2.dp,
tint = colors.surfaceGlass,
),
dockPress = RecipeGlassStyle(
refraction = 0f,
curve = 0f,
edge = 0.03f,
dispersion = 0.0f,
saturation = 1f,
contrast = 1f,
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(
val refraction: Float,
val curve: Float,
val edge: Float,
val dispersion: Float,
val saturation: Float,
val contrast: Float,
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.ProvidableCompositionLocal
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
/**
* Recipe theme entry point (CONTEXT D-14, D-15).
@@ -32,35 +33,36 @@ public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
public fun RecipeTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
val recipeGlass = remember(recipeColors) { recipeGlassFor(recipeColors) }
CompositionLocalProvider(
LocalRecipeColors provides recipeColors,
LocalRecipeTypography provides DefaultRecipeTypography,
LocalRecipeSpacing provides DefaultRecipeSpacing,
LocalRecipeShapes provides DefaultRecipeShapes,
LocalRecipeGlass provides DefaultRecipeGlass,
LocalRecipeGlass provides recipeGlass,
content = content,
)
}
public object RecipeTheme {
public val colors: RecipeColors
object RecipeTheme {
val colors: RecipeColors
@Composable @ReadOnlyComposable
get() = LocalRecipeColors.current
public val typography: RecipeTypography
val typography: RecipeTypography
@Composable @ReadOnlyComposable
get() = LocalRecipeTypography.current
public val spacing: RecipeSpacing
val spacing: RecipeSpacing
@Composable @ReadOnlyComposable
get() = LocalRecipeSpacing.current
public val shapes: RecipeShapes
val shapes: RecipeShapes
@Composable @ReadOnlyComposable
get() = LocalRecipeShapes.current
public val glass: RecipeGlass
val glass: 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

@@ -14,6 +14,7 @@ koin = "4.2.1"
koin-plugin = "1.0.0-RC2"
kotlin = "2.3.20"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.2"
kotlinx-serialization = "1.7.3"
ktor = "3.4.2"
lokksmith = "0.13.0"
@@ -33,6 +34,7 @@ kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", versio
# kotlinx.serialization (shared DTOs — D-27)
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
@@ -90,6 +92,7 @@ lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref =
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
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-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }

View File

@@ -23,11 +23,15 @@ kotlin {
sourceSets {
commonMain.dependencies {
// 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
// forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers
// (composeApp, server) inherit the @Serializable runtime without each
// re-declaring it.
// and kotlinx.datetime are the only allowed runtime dependencies in
// shared/commonMain — D-19 / INFRA-06 forbids Ktor, Compose, SQLDelight,
// Koin, Kermit. `api(...)` so consumers (composeApp, server) inherit the
// @Serializable runtime + datetime types without each re-declaring them.
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 {

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,
)

Some files were not shown because too many files have changed in this diff Show More