Compare commits
4 Commits
815c4f4efc
...
c017a8e777
| Author | SHA1 | Date | |
|---|---|---|---|
| c017a8e777 | |||
| 6d38b8b775 | |||
| ae4186d9fa | |||
| 2d2556fd26 |
@@ -74,14 +74,16 @@ dev.ulfrx.recipe/
|
|||||||
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
|
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── theme/ # Colors, typography, Liquid glass style tokens
|
│ ├── theme/ # Colors, typography, Liquid glass style tokens
|
||||||
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
|
│ ├── components/ # Shared, stateless (VM-free) Recipe-styled composables built on Compose Unstyled where useful
|
||||||
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
|
│ └── screens/{recipes,planner,pantry,shopping,recipedetail}/ # Each with screen + ViewModel
|
||||||
├── data/{local,remote,repository}/
|
├── data/{local,remote,repository}/
|
||||||
└── domain/ # Client-only logic; shared/ handles cross-cutting
|
└── domain/ # Client-only logic; shared/ handles cross-cutting
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above.
|
**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above.
|
||||||
|
|
||||||
|
**Rule:** A `screens/` package is a *stateful* UI feature (screen + ViewModel), not necessarily a nav route. `recipedetail` presents as a modal bottom sheet and is opened from multiple hosts (search, later planner) — it lives under `screens/` because it owns a ViewModel, while its leaf widgets (`IngredientRow`, `NutritionSummary`) stay in `components/`, which is reserved for stateless, VM-free composables.
|
||||||
|
|
||||||
## Non-negotiable conventions
|
## Non-negotiable conventions
|
||||||
|
|
||||||
1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor.
|
1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor.
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ kotlin {
|
|||||||
// ASWebAuthenticationSession integration directly from Kotlin.
|
// ASWebAuthenticationSession integration directly from Kotlin.
|
||||||
implementation(libs.ktor.clientDarwin)
|
implementation(libs.ktor.clientDarwin)
|
||||||
}
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
@@ -22,12 +22,35 @@
|
|||||||
<!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
|
<!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
|
||||||
<string name="search_placeholder">Szukaj…</string>
|
<string name="search_placeholder">Szukaj…</string>
|
||||||
|
|
||||||
<!-- Phase 2.1 — Search screen scaffolding (curated landing in State B; results in State C) -->
|
<!-- Phase 2.1 — Search screen scaffolding (results in State C) -->
|
||||||
<string name="search_screen_curated_title">Tu pojawią się szybkie skróty</string>
|
|
||||||
<string name="search_screen_curated_subtitle">Sugestie wyszukiwania pojawią się wraz z rozwojem aplikacji.</string>
|
|
||||||
<string name="search_screen_empty_results_title">Brak wyników</string>
|
<string name="search_screen_empty_results_title">Brak wyników</string>
|
||||||
<string name="search_screen_empty_results_subtitle">Zacznij pisać, aby wyszukać.</string>
|
<string name="search_screen_empty_results_subtitle">Zacznij pisać, aby wyszukać.</string>
|
||||||
|
|
||||||
|
<!-- Phase 2.1 — Recipe catalog card meta (Phase 11 polishes copy + plurals) -->
|
||||||
|
<string name="recipe_card_minutes_format">%1$d min</string>
|
||||||
|
<string name="recipe_card_kcal_format">%1$d kcal</string>
|
||||||
|
|
||||||
|
<!-- Phase 2.1 — Nutrition facts widget (reusable across recipe detail, planner, …) -->
|
||||||
|
<string name="nutrition_label">Wartości odżywcze</string>
|
||||||
|
<string name="nutrition_macro_kcal">kcal</string>
|
||||||
|
<string name="nutrition_macro_protein">białko</string>
|
||||||
|
<string name="nutrition_macro_fat">tłuszcz</string>
|
||||||
|
<string name="nutrition_macro_carbs">węglowodany</string>
|
||||||
|
<string name="nutrition_grams_format">%1$dg</string>
|
||||||
|
|
||||||
|
<!-- Phase 2.1 — Ingredient row widget (reusable across recipe detail, planner, …) -->
|
||||||
|
<string name="ingredient_substitute_a11y">Zamień składnik</string>
|
||||||
|
|
||||||
|
<!-- Phase 2.1 — Recipe detail sheet (UI-only view; planner wiring lands in later phases) -->
|
||||||
|
<string name="recipe_detail_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) -->
|
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
||||||
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
||||||
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
|
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
|
||||||
|
|||||||
@@ -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.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||||
|
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.plugin.module.dsl.viewModel
|
import org.koin.plugin.module.dsl.viewModel
|
||||||
|
|
||||||
val shellModule =
|
val shellModule =
|
||||||
module {
|
module {
|
||||||
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
|
|
||||||
// Active-tab tracking lives in TabNavigator (a `remember`-scoped state holder
|
|
||||||
// owned by AppShell), not in a shell-level VM, so there is no ShellViewModel
|
|
||||||
// to register.
|
|
||||||
viewModel<HomeViewModel>()
|
viewModel<HomeViewModel>()
|
||||||
viewModel<PlannerViewModel>()
|
viewModel<PlannerViewModel>()
|
||||||
viewModel<PantryViewModel>()
|
viewModel<PantryViewModel>()
|
||||||
viewModel<ShoppingViewModel>()
|
viewModel<ShoppingViewModel>()
|
||||||
|
|
||||||
// Shell-wide search VM — single global state machine (closed / open
|
|
||||||
// unfocused / open focused) shared by the SearchScreen body and the
|
|
||||||
// SearchPillRow chrome. Per-tab SearchViewModels were retired when search
|
|
||||||
// moved from per-tab inline overlay to a shell-level destination.
|
|
||||||
viewModel<ShellSearchViewModel>()
|
viewModel<ShellSearchViewModel>()
|
||||||
|
viewModel<RecipeCatalogViewModel>()
|
||||||
|
viewModel<RecipeDetailViewModel>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,8 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
|
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
|
||||||
* (`saveState=true`/`restoreState=true`).
|
* (`saveState=true`/`restoreState=true`).
|
||||||
*
|
*
|
||||||
* Phase 5+ introduces detail screens with their own VM scopes; at that point
|
* Recipe detail is a modal bottom sheet (not a nav destination), so it needs no
|
||||||
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
|
* per-entry VM scope here; its VM is hosted by the surface that opens it.
|
||||||
* specifically (passed via `entryDecorators = listOf(...)`).
|
|
||||||
*
|
*
|
||||||
* ## Search note
|
* ## Search note
|
||||||
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not
|
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 4–5 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 3–4 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ś 12–15 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. 15–18 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)
|
||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -14,32 +15,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
import com.composables.icons.lucide.Search
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
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 dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.search_screen_curated_subtitle
|
|
||||||
import recipe.composeapp.generated.resources.search_screen_curated_title
|
|
||||||
import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle
|
import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle
|
||||||
import recipe.composeapp.generated.resources.search_screen_empty_results_title
|
import recipe.composeapp.generated.resources.search_screen_empty_results_title
|
||||||
|
|
||||||
/**
|
|
||||||
* Global search destination — overlays the active tab when
|
|
||||||
* [ShellSearchViewModel.state.isOpen] is true. Hosted by `AppShell`, not by the
|
|
||||||
* tab `NavDisplay`, so navigating in/out doesn't disturb per-tab back stacks.
|
|
||||||
*
|
|
||||||
* Two body modes driven by `state.isFocused`:
|
|
||||||
* - **B (unfocused)** — curated landing. v1 placeholder copy; later phases will
|
|
||||||
* surface recents, quick filters, and per-tab shortcuts here.
|
|
||||||
* - **C (focused)** — live search. v1 shows an empty-results hint until per-
|
|
||||||
* feature SearchSources are wired in Phase 5/6/8/9.
|
|
||||||
*
|
|
||||||
* The search input pill itself lives in the bottom chrome (see `SearchChrome.kt`),
|
|
||||||
* not on this screen — keeping the keyboard-adjacent affordance consistent with
|
|
||||||
* the rest of the shell.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(viewModel: ShellSearchViewModel) {
|
fun SearchScreen(
|
||||||
|
viewModel: ShellSearchViewModel,
|
||||||
|
catalogViewModel: RecipeCatalogViewModel,
|
||||||
|
detailViewModel: RecipeDetailViewModel,
|
||||||
|
catalogGridState: LazyGridState,
|
||||||
|
onPlanRecipe: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -47,6 +42,7 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(RecipeTheme.colors.background),
|
.background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
|
if (state.isFocused) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -54,19 +50,24 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
|
|||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
.padding(top = RecipeTheme.spacing.xl),
|
.padding(top = RecipeTheme.spacing.xl),
|
||||||
) {
|
) {
|
||||||
if (state.isFocused) {
|
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = Lucide.Search,
|
icon = Lucide.Search,
|
||||||
title = stringResource(Res.string.search_screen_empty_results_title),
|
title = stringResource(Res.string.search_screen_empty_results_title),
|
||||||
subtitle = stringResource(Res.string.search_screen_empty_results_subtitle),
|
subtitle = stringResource(Res.string.search_screen_empty_results_subtitle),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
EmptyState(
|
RecipeCatalogGrid(
|
||||||
icon = Lucide.Search,
|
state = catalogState,
|
||||||
title = stringResource(Res.string.search_screen_curated_title),
|
onRecipeClick = detailViewModel::open,
|
||||||
subtitle = stringResource(Res.string.search_screen_curated_subtitle),
|
gridState = catalogGridState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
RecipeDetailSheet(
|
||||||
|
viewModel = detailViewModel,
|
||||||
|
onPlanRecipe = onPlanRecipe,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.screens.search.catalog
|
||||||
|
|
||||||
|
data class RecipeCatalogState(
|
||||||
|
val cards: List<RecipeCardUi> = emptyList(),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.animation.togetherWith
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -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.components.glass.rememberGlassBackdropState
|
||||||
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||||
|
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@@ -32,6 +35,9 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
fun AppShell(modifier: Modifier = Modifier) {
|
fun AppShell(modifier: Modifier = Modifier) {
|
||||||
val navigator = remember { TabNavigator() }
|
val navigator = remember { TabNavigator() }
|
||||||
val searchVm: ShellSearchViewModel = koinViewModel()
|
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||||
|
val catalogVm: RecipeCatalogViewModel = koinViewModel()
|
||||||
|
val detailVm: RecipeDetailViewModel = koinViewModel()
|
||||||
|
val catalogGridState = rememberLazyGridState()
|
||||||
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
||||||
val backdropState = rememberGlassBackdropState()
|
val backdropState = rememberGlassBackdropState()
|
||||||
|
|
||||||
@@ -56,7 +62,12 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
label = "AppShell body",
|
label = "AppShell body",
|
||||||
) { searchOpen ->
|
) { searchOpen ->
|
||||||
if (searchOpen) {
|
if (searchOpen) {
|
||||||
SearchScreen(viewModel = searchVm)
|
SearchScreen(
|
||||||
|
viewModel = searchVm,
|
||||||
|
catalogViewModel = catalogVm,
|
||||||
|
detailViewModel = detailVm,
|
||||||
|
catalogGridState = catalogGridState,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
RootNavDisplay(
|
RootNavDisplay(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ private fun DockBarExpanded(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val anim = rememberDockOverlayAnimations(
|
val anim =
|
||||||
|
rememberDockOverlayAnimations(
|
||||||
pressState = pressState,
|
pressState = pressState,
|
||||||
activeIndex = activeIndex,
|
activeIndex = activeIndex,
|
||||||
tabBounds = tabBounds,
|
tabBounds = tabBounds,
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
private val PressOverlayBleed = 4.dp
|
private val PressOverlayBleed = 4.dp
|
||||||
private const val SlideOutwardStiffness = Spring.StiffnessMediumLow
|
private const val SLIDE_OUTWARD_STIFFNESS = Spring.StiffnessMediumLow
|
||||||
private const val SlideSettleStiffness = Spring.StiffnessHigh
|
private const val SLIDE_SETTLE_STIFFNESS = Spring.StiffnessHigh
|
||||||
private const val OverlayFadeInDurationMs = 120
|
private const val OVERLAY_FADE_IN_DURATION_MS = 120
|
||||||
private const val OverlayFadeOutDurationMs = 40
|
private const val OVERLAY_FADE_OUT_DURATION_MS = 40
|
||||||
private const val SettleEpsilonPx = 0.5f
|
private const val SETTLE_EPSILON_PX = 0.5f
|
||||||
|
|
||||||
internal data class DockOverlayAnimations(
|
internal data class DockOverlayAnimations(
|
||||||
val overlayCenterX: Float,
|
val overlayCenterX: Float,
|
||||||
@@ -72,23 +72,27 @@ internal fun rememberDockOverlayAnimations(
|
|||||||
activeCenterX,
|
activeCenterX,
|
||||||
spring(
|
spring(
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
stiffness = SlideSettleStiffness,
|
stiffness = SLIDE_SETTLE_STIFFNESS,
|
||||||
visibilityThreshold = SettleEpsilonPx,
|
visibilityThreshold = SETTLE_EPSILON_PX,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
!wasPressed -> {
|
!wasPressed -> {
|
||||||
wasPressed = true
|
wasPressed = true
|
||||||
centerAnim.animateTo(
|
centerAnim.animateTo(
|
||||||
clampedPressX,
|
clampedPressX,
|
||||||
spring(
|
spring(
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
stiffness = SlideOutwardStiffness,
|
stiffness = SLIDE_OUTWARD_STIFFNESS,
|
||||||
visibilityThreshold = SettleEpsilonPx,
|
visibilityThreshold = SETTLE_EPSILON_PX,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> centerAnim.snapTo(clampedPressX)
|
|
||||||
|
else -> {
|
||||||
|
centerAnim.snapTo(clampedPressX)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,13 +105,14 @@ internal fun rememberDockOverlayAnimations(
|
|||||||
activeAlphaAnim.snapTo(0f)
|
activeAlphaAnim.snapTo(0f)
|
||||||
overlayAlphaAnim.animateTo(
|
overlayAlphaAnim.animateTo(
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = tween(OverlayFadeInDurationMs, easing = FastOutSlowInEasing),
|
animationSpec = tween(OVERLAY_FADE_IN_DURATION_MS, easing = FastOutSlowInEasing),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect
|
if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect
|
||||||
releaseSlideStartX = centerAnim.value
|
releaseSlideStartX = centerAnim.value
|
||||||
if (overlayAlphaAnim.value < 1f) {
|
if (overlayAlphaAnim.value < 1f) {
|
||||||
val tailMs = ((1f - overlayAlphaAnim.value) * OverlayFadeInDurationMs)
|
val tailMs =
|
||||||
|
((1f - overlayAlphaAnim.value) * OVERLAY_FADE_IN_DURATION_MS)
|
||||||
.toInt()
|
.toInt()
|
||||||
.coerceAtLeast(0)
|
.coerceAtLeast(0)
|
||||||
if (tailMs > 0) {
|
if (tailMs > 0) {
|
||||||
@@ -116,19 +121,19 @@ internal fun rememberDockOverlayAnimations(
|
|||||||
}
|
}
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
!centerAnim.isRunning &&
|
!centerAnim.isRunning &&
|
||||||
abs(centerAnim.value - activeCenterXState.value) < SettleEpsilonPx
|
abs(centerAnim.value - activeCenterXState.value) < SETTLE_EPSILON_PX
|
||||||
}.first { it }
|
}.first { it }
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch {
|
launch {
|
||||||
overlayAlphaAnim.animateTo(
|
overlayAlphaAnim.animateTo(
|
||||||
targetValue = 0f,
|
targetValue = 0f,
|
||||||
animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing),
|
animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
launch {
|
launch {
|
||||||
activeAlphaAnim.animateTo(
|
activeAlphaAnim.animateTo(
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing),
|
animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,15 +141,19 @@ internal fun rememberDockOverlayAnimations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val releaseSlideProgress = run {
|
val releaseSlideProgress =
|
||||||
|
run {
|
||||||
val start = releaseSlideStartX
|
val start = releaseSlideStartX
|
||||||
if (start == null) {
|
if (start == null) {
|
||||||
0f
|
0f
|
||||||
} else {
|
} else {
|
||||||
val target = activeCenterXState.value
|
val target = activeCenterXState.value
|
||||||
val total = abs(target - start)
|
val total = abs(target - start)
|
||||||
if (total < 1f) 0f
|
if (total < 1f) {
|
||||||
else (abs(centerAnim.value - start) / total).coerceIn(0f, 1f)
|
0f
|
||||||
|
} else {
|
||||||
|
(abs(centerAnim.value - start) / total).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val overlayPeakProgress = overlayAlphaAnim.value * (1f - releaseSlideProgress)
|
val overlayPeakProgress = overlayAlphaAnim.value * (1f - releaseSlideProgress)
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.util.lerp
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.times
|
import androidx.compose.ui.unit.times
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val PressOverlayVerticalInset = 0.dp
|
private val PressOverlayVerticalInset = 0.dp
|
||||||
private val ActiveIndicatorVerticalInset = 5.dp
|
private val ActiveIndicatorVerticalInset = 5.dp
|
||||||
private const val PressOverlayScale = 1.22f
|
private const val PRESS_OVERLAY_SCALE = 1.22f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DockSubstrate(cornerRadius: Dp) {
|
internal fun DockSubstrate(cornerRadius: Dp) {
|
||||||
@@ -49,7 +49,8 @@ internal fun DockActiveIndicatorLayer(
|
|||||||
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
|
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
|
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
|
||||||
.width(with(density) { bbox.widthPx.toDp() })
|
.width(with(density) { bbox.widthPx.toDp() })
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
@@ -77,13 +78,14 @@ internal fun DockPressOverlayLayer(
|
|||||||
val dockHeightPx = with(density) { dockHeight.toPx() }
|
val dockHeightPx = with(density) { dockHeight.toPx() }
|
||||||
val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() }
|
val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() }
|
||||||
val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f
|
val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f
|
||||||
val scaleX = lerp(1f, PressOverlayScale, overlayPeakProgress)
|
val scaleX = lerp(1f, PRESS_OVERLAY_SCALE, overlayPeakProgress)
|
||||||
val scaleY = lerp(activeStartScaleY, PressOverlayScale, overlayPeakProgress)
|
val scaleY = lerp(activeStartScaleY, PRESS_OVERLAY_SCALE, overlayPeakProgress)
|
||||||
|
|
||||||
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
|
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
|
||||||
val leftPx = overlayCenterX - overlayWidthPx / 2f
|
val leftPx = overlayCenterX - overlayWidthPx / 2f
|
||||||
GlassSurface(
|
GlassSurface(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.offset { IntOffset(leftPx.roundToInt(), 0) }
|
.offset { IntOffset(leftPx.roundToInt(), 0) }
|
||||||
.width(with(density) { overlayWidthPx.toDp() })
|
.width(with(density) { overlayWidthPx.toDp() })
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
@@ -91,8 +93,7 @@ internal fun DockPressOverlayLayer(
|
|||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.scaleX = scaleX
|
this.scaleX = scaleX
|
||||||
this.scaleY = scaleY
|
this.scaleY = scaleY
|
||||||
}
|
}.alpha(overlayAlpha),
|
||||||
.alpha(overlayAlpha),
|
|
||||||
cornerRadius = cornerRadius,
|
cornerRadius = cornerRadius,
|
||||||
glassStyle = RecipeTheme.glass.dockPress,
|
glassStyle = RecipeTheme.glass.dockPress,
|
||||||
tint = RecipeTheme.colors.surfaceGlassOverlay,
|
tint = RecipeTheme.colors.surfaceGlassOverlay,
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private val DockTabIconSize = 18.dp
|
private val DockTabIconSize = 18.dp
|
||||||
private val DockTabIconLabelGap = 2.dp
|
private val DockTabIconLabelGap = 2.dp
|
||||||
private const val DockTabLabelFontSizeSp = 11
|
private const val DOCK_TAB_LABEL_FONT_SIZE_SP = 11
|
||||||
private const val DockTabLabelLineHeightSp = 13
|
private const val DOCK_TAB_LABEL_LINE_HEIGHT_SP = 13
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DockTabRow(
|
internal fun DockTabRow(
|
||||||
@@ -53,7 +53,8 @@ internal fun DockTabRow(
|
|||||||
) {
|
) {
|
||||||
destinations.forEachIndexed { index, destination ->
|
destinations.forEachIndexed { index, destination ->
|
||||||
val cellBounds = tabBounds[index]
|
val cellBounds = tabBounds[index]
|
||||||
val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) {
|
val contentOffsetPx =
|
||||||
|
if (cellBounds != null && dockWidthPx > 0f) {
|
||||||
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
|
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
|
||||||
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
|
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
|
||||||
bbox.centerPx - cellCenterX
|
bbox.centerPx - cellCenterX
|
||||||
@@ -65,7 +66,8 @@ internal fun DockTabRow(
|
|||||||
isActive = index == activeIndex,
|
isActive = index == activeIndex,
|
||||||
contentOffsetPx = contentOffsetPx,
|
contentOffsetPx = contentOffsetPx,
|
||||||
onSelect = { onTabSelectFromA11y(destination) },
|
onSelect = { onTabSelectFromA11y(destination) },
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.onGloballyPositioned { coords ->
|
.onGloballyPositioned { coords ->
|
||||||
@@ -94,7 +96,8 @@ private fun DockTabItem(
|
|||||||
val a11yLabel = if (isActive) "$label, aktywna" else label
|
val a11yLabel = if (isActive) "$label, aktywna" else label
|
||||||
val tint = RecipeTheme.colors.content
|
val tint = RecipeTheme.colors.content
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.semantics {
|
modifier =
|
||||||
|
modifier.semantics {
|
||||||
role = Role.Tab
|
role = Role.Tab
|
||||||
selected = isActive
|
selected = isActive
|
||||||
contentDescription = a11yLabel
|
contentDescription = a11yLabel
|
||||||
@@ -119,10 +122,11 @@ private fun DockTabItem(
|
|||||||
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
|
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
|
||||||
BasicText(
|
BasicText(
|
||||||
text = label,
|
text = label,
|
||||||
style = RecipeTheme.typography.label.copy(
|
style =
|
||||||
|
RecipeTheme.typography.label.copy(
|
||||||
color = tint,
|
color = tint,
|
||||||
fontSize = DockTabLabelFontSizeSp.sp,
|
fontSize = DOCK_TAB_LABEL_FONT_SIZE_SP.sp,
|
||||||
lineHeight = DockTabLabelLineHeightSp.sp,
|
lineHeight = DOCK_TAB_LABEL_LINE_HEIGHT_SP.sp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ data object RecipeGlass {
|
|||||||
|
|
||||||
val dockPress: RecipeGlassStyle =
|
val dockPress: RecipeGlassStyle =
|
||||||
RecipeGlassStyle(
|
RecipeGlassStyle(
|
||||||
refraction = 0.20f,
|
refraction = 0.05f,
|
||||||
curve = 0.05f,
|
curve = 0.25f,
|
||||||
edge = 0.04f,
|
edge = 0.04f,
|
||||||
dispersion = 0.03f,
|
dispersion = 0.0f,
|
||||||
saturation = 0.6f,
|
saturation = 1.0f,
|
||||||
contrast = 1.8f,
|
contrast = 1.0f,
|
||||||
frost = 0.dp,
|
frost = 0.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,11 +23,15 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
|
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
|
||||||
// is the only allowed runtime dependency in shared/commonMain — D-19 / INFRA-06
|
// and kotlinx.datetime are the only allowed runtime dependencies in
|
||||||
// forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers
|
// shared/commonMain — D-19 / INFRA-06 forbids Ktor, Compose, SQLDelight,
|
||||||
// (composeApp, server) inherit the @Serializable runtime without each
|
// Koin, Kermit. `api(...)` so consumers (composeApp, server) inherit the
|
||||||
// re-declaring it.
|
// @Serializable runtime + datetime types without each re-declaring them.
|
||||||
api(libs.kotlinx.serializationJson)
|
api(libs.kotlinx.serializationJson)
|
||||||
|
// Domain types need Instant (SyncMeta.updatedAt/createdAt/deletedAt) and
|
||||||
|
// LocalDate (PlanEntry.date). kotlinx.datetime is the project's locked
|
||||||
|
// datetime lib per CLAUDE.md; pure types, no platform deps.
|
||||||
|
api(libs.kotlinx.datetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
?: ""
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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\""))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user