Compare commits

...

4 Commits

Author SHA1 Message Date
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
35 changed files with 2698 additions and 152 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

@@ -108,6 +108,9 @@ kotlin {
// ASWebAuthenticationSession integration directly from Kotlin.
implementation(libs.ktor.clientDarwin)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

View File

@@ -22,12 +22,35 @@
<!-- 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_plan_button">Zaplanuj</string>
<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="recipe_detail_handle_a11y">Przeciągnij, aby zamknąć szczegóły przepisu</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_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>

View File

@@ -4,24 +4,19 @@ import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
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<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<RecipeDetailViewModel>()
}

View File

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

View File

@@ -0,0 +1,283 @@
package dev.ulfrx.recipe.ui.components.recipe
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.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.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.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.Shuffle
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.ingredient_substitute_a11y
import kotlin.math.round
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,
)
@Composable
fun IngredientRow(
slot: RecipeIngredientSlotUi,
modifier: Modifier = Modifier,
selectedOptionId: String = slot.default.id,
onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val options = slot.options
val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default
val swappable = slot.alternatives.isNotEmpty() && onSelect != null
var expanded by remember(slot.id) { mutableStateOf(false) }
Column(
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(CornerRadius))
.background(colors.surface)
.animateContentSize(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = PaddingHorizontal, vertical = PaddingVertical),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
BasicText(
text = selected.name,
style =
typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Medium,
fontSize = NameTextSize,
lineHeight = LineHeight,
),
modifier = Modifier.weight(1f),
)
if (swappable) {
SwapToggle(onClick = { expanded = !expanded })
}
AmountLabel(amount = selected.amount, unit = selected.unit)
}
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 AmountLabel(
amount: Double,
unit: String,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
BasicText(
text = formatAmount(amount),
style =
typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Medium,
fontSize = NameTextSize,
lineHeight = LineHeight,
),
)
BasicText(
text = unit,
style =
typography.body.copy(
color = colors.contentMuted,
fontSize = UnitTextSize,
lineHeight = LineHeight,
),
)
}
}
@Composable
private fun SwapToggle(onClick: () -> Unit) {
val colors = RecipeTheme.colors
UnstyledButton(
onClick = onClick,
modifier = Modifier.size(ToggleSize),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
UnstyledIcon(
imageVector = Lucide.Shuffle,
contentDescription = stringResource(Res.string.ingredient_substitute_a11y),
tint = colors.contentMuted,
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 = formatAmount(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),
)
}
}
}
private fun formatAmount(value: Double): String {
val scaled = round(value * 10.0).toLong()
val whole = scaled / 10
val frac = (scaled % 10).toInt()
return if (frac == 0) whole.toString() else "$whole,$frac"
}
internal val RecipeIngredientSlotUi.options: List<RecipeIngredientOptionUi>
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 CornerRadius = 12.dp
private val PaddingHorizontal = 12.dp
private val PaddingVertical = 16.dp
private val NameTextSize = 13.sp
private val UnitTextSize = 12.sp
private val LineHeight = 18.sp
private val ToggleSize = 28.dp
private val ToggleIconSize = 14.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,114 @@
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.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,
) {
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),
)
MacroCard(
modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.protein),
label = stringResource(Res.string.nutrition_macro_protein),
)
MacroCard(
modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.fat),
label = stringResource(Res.string.nutrition_macro_fat),
)
MacroCard(
modifier = Modifier.weight(1f),
value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs),
label = stringResource(Res.string.nutrition_macro_carbs),
)
}
}
@Composable
private fun MacroCard(
value: String,
label: String,
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 = colors.content,
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,509 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.core.BottomSheetScope
import com.composables.core.DragIndication
import com.composables.core.ModalBottomSheet
import com.composables.core.Scrim
import com.composables.core.Sheet
import com.composables.core.SheetDetent
import com.composables.core.rememberModalBottomSheetState
import com.composables.icons.lucide.Calendar
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Minus
import com.composables.icons.lucide.Plus
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.nutrition_label
import recipe.composeapp.generated.resources.recipe_card_minutes_format
import recipe.composeapp.generated.resources.recipe_detail_handle_a11y
import recipe.composeapp.generated.resources.recipe_detail_plan_button
import recipe.composeapp.generated.resources.recipe_detail_section_ingredients
import recipe.composeapp.generated.resources.recipe_detail_section_steps
import recipe.composeapp.generated.resources.recipe_detail_servings_decrement_a11y
import recipe.composeapp.generated.resources.recipe_detail_servings_increment_a11y
import recipe.composeapp.generated.resources.recipe_detail_servings_label
import recipe.composeapp.generated.resources.recipe_detail_step_number_format
import recipe.composeapp.generated.resources.sample_recipe
@Composable
fun RecipeDetailSheet(
viewModel: RecipeDetailViewModel,
onPlanRecipe: (recipeId: String) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val sheetState =
rememberModalBottomSheetState(
initialDetent = SheetDetent.Hidden,
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded),
)
val ready = state as? RecipeDetailState.Ready
LaunchedEffect(ready != null) {
sheetState.targetDetent = if (ready != null) SheetDetent.FullyExpanded else SheetDetent.Hidden
}
// Only caller of dismiss(): a drag that settles the sheet at Hidden while the VM still holds
// the recipe. Programmatic closes must set targetDetent = Hidden and let this fire — calling
// dismiss() directly would clear the recipe mid-animation and blank the closing sheet.
LaunchedEffect(sheetState.isIdle, sheetState.currentDetent) {
if (sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden && ready != null) {
viewModel.dismiss()
}
}
ModalBottomSheet(state = sheetState) {
Scrim(
scrimColor = ScrimColor,
enter = fadeIn(tween(ScrimFadeMillis)),
exit = fadeOut(tween(ScrimFadeMillis)),
)
Sheet(
modifier = Modifier.fillMaxWidth(),
backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SheetCornerRadius, topEnd = SheetCornerRadius),
) {
ready?.let {
RecipeDetailContent(
ready = it,
onServingsChange = viewModel::setServings,
onSelectSubstitution = viewModel::selectSubstitution,
onPlanRecipe = onPlanRecipe,
)
}
}
}
}
@Composable
private fun BottomSheetScope.RecipeDetailContent(
ready: RecipeDetailState.Ready,
onServingsChange: (Int) -> Unit,
onSelectSubstitution: (String, String) -> Unit,
onPlanRecipe: (String) -> Unit,
) {
val colors = RecipeTheme.colors
val typography = RecipeTheme.typography
val spacing = RecipeTheme.spacing
val scrollState = rememberScrollState()
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val handleLabel = stringResource(Res.string.recipe_detail_handle_a11y)
val backdrop = rememberGlassBackdropState()
val detail = ready.recipe
val servings = ready.servings
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION)) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
RecipeHero()
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
BasicText(
text = detail.title,
style =
typography.display.copy(
color = colors.content,
fontSize = TitleTextSize,
lineHeight = TitleLineHeight,
fontWeight = FontWeight.Bold,
),
)
Spacer(Modifier.height(spacing.sm))
MetaRow(minutes = detail.cookingMinutes)
Spacer(Modifier.height(spacing.xl))
NutritionSection(nutrition = detail.nutrition.scaledBy(servings))
Spacer(Modifier.height(spacing.xl))
ServingsSection(servings = servings, onServingsChange = onServingsChange)
Spacer(Modifier.height(spacing.xl))
IngredientsSection(
ingredients = detail.ingredients,
servings = servings,
substitutions = ready.substitutions,
onSelectSubstitution = onSelectSubstitution,
)
Spacer(Modifier.height(spacing.xl))
StepsSection(steps = detail.steps)
Spacer(Modifier.height(bottomInset + spacing.xxl))
}
}
}
DragIndication(
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = spacing.sm)
.semantics { contentDescription = handleLabel }
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface.copy(alpha = 0.85f))
.width(HandleWidth)
.height(HandleHeight),
)
PlanButton(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = spacing.lg, end = spacing.lg),
onClick = { onPlanRecipe(detail.id) },
)
}
}
}
@Composable
private fun RecipeHero() {
val colors = RecipeTheme.colors
Box(modifier = Modifier.fillMaxWidth().height(HeroHeight)) {
Box(modifier = Modifier.fillMaxSize().background(colors.surfaceGlass))
Image(
painter = painterResource(Res.drawable.sample_recipe),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier =
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops =
arrayOf(
0.5f to Color.Transparent,
1f to colors.background,
),
),
),
)
}
}
@Composable
private fun MetaRow(minutes: Int) {
val colors = RecipeTheme.colors
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
UnstyledIcon(
imageVector = Lucide.Clock,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(MetaIconSize),
)
BasicText(
text = stringResource(Res.string.recipe_card_minutes_format, minutes),
style = RecipeTheme.typography.label.copy(color = colors.contentMuted),
)
}
}
@Composable
private fun Section(
title: String,
content: @Composable () -> Unit,
) {
SectionTitle(text = title)
Spacer(Modifier.height(RecipeTheme.spacing.lg))
content()
}
@Composable
private fun SectionTitle(text: String) {
BasicText(
text = text.uppercase(),
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
fontSize = SectionHeaderTextSize,
letterSpacing = SectionHeaderTracking,
fontWeight = FontWeight.Bold,
),
)
}
@Composable
private fun NutritionSection(nutrition: RecipeNutritionUi) {
Section(title = stringResource(Res.string.nutrition_label)) {
NutritionSummary(nutrition = nutrition)
}
}
@Composable
private fun ServingsSection(
servings: Int,
onServingsChange: (Int) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SectionTitle(text = stringResource(Res.string.recipe_detail_servings_label))
ServingsStepper(
servings = servings,
onDecrement = { onServingsChange(servings - 1) },
onIncrement = { onServingsChange(servings + 1) },
)
}
}
@Composable
private fun IngredientsSection(
ingredients: List<RecipeIngredientSlotUi>,
servings: Int,
substitutions: Map<String, String>,
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
) {
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
Column(verticalArrangement = Arrangement.spacedBy(IngredientRowGap)) {
ingredients.forEach { slot ->
IngredientRow(
slot = slot.scaledBy(servings),
selectedOptionId = substitutions[slot.id] ?: slot.default.id,
onSelect =
if (slot.alternatives.isNotEmpty()) {
{ choice -> onSelectSubstitution(slot.id, choice.id) }
} else {
null
},
)
}
}
}
}
@Composable
private fun StepsSection(steps: List<String>) {
Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
Column(verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
steps.forEachIndexed { index, step ->
StepRow(number = index + 1, text = step)
}
}
}
}
@Composable
private fun ServingsStepper(
servings: Int,
onDecrement: () -> Unit,
onIncrement: () -> Unit,
) {
val colors = RecipeTheme.colors
Row(
modifier =
Modifier
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface)
.padding(horizontal = RecipeTheme.spacing.xs),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
StepperButton(
icon = Lucide.Minus,
contentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
enabled = servings > MIN_RECIPE_SERVINGS,
onClick = onDecrement,
)
BasicText(
text = servings.toString(),
style =
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
),
modifier = Modifier.width(ServingsValueWidth),
)
StepperButton(
icon = Lucide.Plus,
contentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
enabled = servings < MAX_RECIPE_SERVINGS,
onClick = onIncrement,
)
}
}
@Composable
private fun StepperButton(
icon: ImageVector,
contentDescription: String,
enabled: Boolean,
onClick: () -> Unit,
) {
val colors = RecipeTheme.colors
UnstyledButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(StepperButtonSize),
) {
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(StepperIconSize),
)
}
}
}
@Composable
private fun StepRow(
number: Int,
text: String,
) {
val colors = RecipeTheme.colors
Row(horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
BasicText(
text = stringResource(Res.string.recipe_detail_step_number_format, number),
style =
RecipeTheme.typography.body.copy(
color = colors.contentMuted,
fontWeight = FontWeight.Medium,
fontSize = StepTextSize,
),
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),
)
}
}
@Composable
private fun PlanButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
GlassSurface(
modifier = modifier.height(PlanButtonHeight),
cornerRadius = PlanButtonHeight / 2,
tint = colors.surfaceGlass,
) {
UnstyledButton(
onClick = onClick,
backgroundColor = Color.Transparent,
contentColor = colors.content,
contentPadding = PaddingValues(horizontal = RecipeTheme.spacing.lg),
modifier = Modifier.fillMaxHeight(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
UnstyledIcon(
imageVector = Lucide.Calendar,
contentDescription = null,
tint = colors.content,
modifier = Modifier.size(PlanButtonIconSize),
)
BasicText(
text = stringResource(Res.string.recipe_detail_plan_button),
style =
RecipeTheme.typography.label.copy(
color = colors.content,
fontWeight = FontWeight.SemiBold,
),
)
}
}
}
}
private const val SHEET_HEIGHT_FRACTION = 0.92f
private const val ScrimFadeMillis = 250
private val ScrimColor = Color.Black.copy(alpha = 0.45f)
private val SheetCornerRadius = 28.dp
private val HeroHeight = 200.dp
private val HandleWidth = 36.dp
private val HandleHeight = 5.dp
private val IngredientRowGap = 6.dp
private val MetaIconSize = 14.dp
private val StepperButtonSize = 30.dp
private val StepperIconSize = 14.dp
private val ServingsValueWidth = 28.dp
private val StepNumberWidth = 20.dp
private val PlanButtonHeight = 36.dp
private val PlanButtonIconSize = 14.dp
private val TitleTextSize = 24.sp
private val TitleLineHeight = 28.sp
private val SectionHeaderTextSize = 11.sp
private val SectionHeaderTracking = 1.sp
private val StepTextSize = 14.sp
private val StepLineHeight = 20.sp

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
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 : ViewModel() {
private val _state = MutableStateFlow<RecipeDetailState>(RecipeDetailState.Hidden)
val state: StateFlow<RecipeDetailState> = _state.asStateFlow()
fun open(recipeId: String) {
_state.value = sampleRecipeDetail(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.Hidden
}
fun dismiss() {
_state.value = RecipeDetailState.Hidden
}
fun setServings(value: Int) =
_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,400 @@
package dev.ulfrx.recipe.ui.screens.recipedetail
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
internal val sampleRecipeDetails: Map<String, RecipeDetailUi> =
listOf(
RecipeDetailUi(
id = "rcp_nalesniki",
title = "Naleśniki z twarogiem",
cookingMinutes = 25,
nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42),
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.",
),
),
RecipeDetailUi(
id = "rcp_owsianka",
title = "Owsianka z owocami i orzechami",
cookingMinutes = 10,
nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38),
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.",
),
),
RecipeDetailUi(
id = "rcp_spaghetti",
title = "Spaghetti bolognese",
cookingMinutes = 40,
nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65),
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.",
),
),
RecipeDetailUi(
id = "rcp_pierogi",
title = "Pierogi ruskie",
cookingMinutes = 90,
nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68),
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ą.",
),
),
RecipeDetailUi(
id = "rcp_kanapka_awokado",
title = "Kanapka z awokado i jajkiem",
cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16),
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.",
),
),
RecipeDetailUi(
id = "rcp_schabowy",
title = "Schabowy z ziemniakami",
cookingMinutes = 60,
nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62),
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ą.",
),
),
RecipeDetailUi(
id = "rcp_salatka_grecka",
title = "Sałatka grecka",
cookingMinutes = 15,
nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12),
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.",
),
),
RecipeDetailUi(
id = "rcp_pomidorowa",
title = "Zupa pomidorowa z ryżem",
cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39),
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.",
),
),
RecipeDetailUi(
id = "rcp_kurczak_curry",
title = "Kurczak curry z ryżem basmati",
cookingMinutes = 45,
nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70),
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.",
),
),
RecipeDetailUi(
id = "rcp_jajecznica",
title = "Jajecznica na maśle ze szczypiorkiem",
cookingMinutes = 8,
nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3),
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.",
),
),
RecipeDetailUi(
id = "rcp_risotto",
title = "Risotto z grzybami leśnymi",
cookingMinutes = 35,
nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66),
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.",
),
),
RecipeDetailUi(
id = "rcp_tortilla",
title = "Tortilla z kurczakiem i warzywami",
cookingMinutes = 20,
nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48),
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ń.",
),
),
RecipeDetailUi(
id = "rcp_smoothie",
title = "Smoothie bananowo-szpinakowe",
cookingMinutes = 5,
nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33),
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.",
),
),
RecipeDetailUi(
id = "rcp_losos",
title = "Łosoś pieczony z brokułami",
cookingMinutes = 30,
nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12),
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.",
),
),
RecipeDetailUi(
id = "rcp_nadziewane_papryki",
title = "Papryki nadziewane kaszą i warzywami",
cookingMinutes = 55,
nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58),
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 sampleRecipeDetail(id: String): RecipeDetailUi? = sampleRecipeDetails[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

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -14,32 +15,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
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_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,
catalogViewModel: RecipeCatalogViewModel,
detailViewModel: RecipeDetailViewModel,
catalogGridState: LazyGridState,
onPlanRecipe: (String) -> Unit = {},
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
Box(
modifier =
@@ -47,6 +42,7 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
if (state.isFocused) {
Box(
modifier =
Modifier
@@ -54,19 +50,24 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
) {
if (state.isFocused) {
EmptyState(
icon = Lucide.Search,
title = stringResource(Res.string.search_screen_empty_results_title),
subtitle = stringResource(Res.string.search_screen_empty_results_subtitle),
)
}
} else {
EmptyState(
icon = Lucide.Search,
title = stringResource(Res.string.search_screen_curated_title),
subtitle = stringResource(Res.string.search_screen_curated_subtitle),
RecipeCatalogGrid(
state = catalogState,
onRecipeClick = detailViewModel::open,
gridState = catalogGridState,
modifier = Modifier.fillMaxSize(),
)
}
RecipeDetailSheet(
viewModel = detailViewModel,
onPlanRecipe = onPlanRecipe,
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -24,6 +25,8 @@ import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel
@@ -32,6 +35,9 @@ import org.koin.compose.viewmodel.koinViewModel
fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel()
val catalogVm: RecipeCatalogViewModel = koinViewModel()
val detailVm: RecipeDetailViewModel = koinViewModel()
val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState()
@@ -56,7 +62,12 @@ fun AppShell(modifier: Modifier = Modifier) {
label = "AppShell body",
) { searchOpen ->
if (searchOpen) {
SearchScreen(viewModel = searchVm)
SearchScreen(
viewModel = searchVm,
catalogViewModel = catalogVm,
detailViewModel = detailVm,
catalogGridState = catalogGridState,
)
} else {
RootNavDisplay(
navigator = navigator,

View File

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

View File

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

View File

@@ -13,18 +13,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.util.lerp
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 PressOverlayScale = 1.22f
private const val PRESS_OVERLAY_SCALE = 1.22f
@Composable
internal fun DockSubstrate(cornerRadius: Dp) {
@@ -49,7 +49,8 @@ internal fun DockActiveIndicatorLayer(
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
Box(
modifier = Modifier
modifier =
Modifier
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
.width(with(density) { bbox.widthPx.toDp() })
.fillMaxHeight()
@@ -77,13 +78,14 @@ internal fun DockPressOverlayLayer(
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, PressOverlayScale, overlayPeakProgress)
val scaleY = lerp(activeStartScaleY, PressOverlayScale, overlayPeakProgress)
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
modifier =
Modifier
.offset { IntOffset(leftPx.roundToInt(), 0) }
.width(with(density) { overlayWidthPx.toDp() })
.fillMaxHeight()
@@ -91,8 +93,7 @@ internal fun DockPressOverlayLayer(
.graphicsLayer {
this.scaleX = scaleX
this.scaleY = scaleY
}
.alpha(overlayAlpha),
}.alpha(overlayAlpha),
cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress,
tint = RecipeTheme.colors.surfaceGlassOverlay,

View File

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

View File

@@ -17,12 +17,12 @@ data object RecipeGlass {
val dockPress: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.20f,
curve = 0.05f,
refraction = 0.05f,
curve = 0.25f,
edge = 0.04f,
dispersion = 0.03f,
saturation = 0.6f,
contrast = 1.8f,
dispersion = 0.0f,
saturation = 1.0f,
contrast = 1.0f,
frost = 0.dp,
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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