diff --git a/composeApp/src/commonMain/composeResources/drawable/sample_recipe.jpg b/composeApp/src/commonMain/composeResources/drawable/sample_recipe.jpg
new file mode 100644
index 0000000..912bd8e
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/sample_recipe.jpg differ
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 94dc2ad..b356e49 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -22,12 +22,14 @@
Szukaj…
-
- Tu pojawią się szybkie skróty
- Sugestie wyszukiwania pojawią się wraz z rozwojem aplikacji.
+
Brak wyników
Zacznij pisać, aby wyszukać.
+
+ %1$d min
+ %1$d kcal
+
Otwórz wyszukiwanie
Wyczyść i ukryj klawiaturę
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
index b73a712..a9e8049 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
@@ -4,24 +4,17 @@ import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
+import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
val shellModule =
module {
- // Tab ViewModels — empty-state-only this phase; feature phases extend them.
- // Active-tab tracking lives in TabNavigator (a `remember`-scoped state holder
- // owned by AppShell), not in a shell-level VM, so there is no ShellViewModel
- // to register.
viewModel()
viewModel()
viewModel()
viewModel()
-
- // 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()
+ viewModel()
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
index d369ccf..468ec45 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -14,32 +15,22 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.empty.EmptyState
+import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
+import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
-import recipe.composeapp.generated.resources.search_screen_curated_subtitle
-import recipe.composeapp.generated.resources.search_screen_curated_title
import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle
import recipe.composeapp.generated.resources.search_screen_empty_results_title
-/**
- * Global search destination — overlays the active tab when
- * [ShellSearchViewModel.state.isOpen] is true. Hosted by `AppShell`, not by the
- * tab `NavDisplay`, so navigating in/out doesn't disturb per-tab back stacks.
- *
- * Two body modes driven by `state.isFocused`:
- * - **B (unfocused)** — curated landing. v1 placeholder copy; later phases will
- * surface recents, quick filters, and per-tab shortcuts here.
- * - **C (focused)** — live search. v1 shows an empty-results hint until per-
- * feature SearchSources are wired in Phase 5/6/8/9.
- *
- * The search input pill itself lives in the bottom chrome (see `SearchChrome.kt`),
- * not on this screen — keeping the keyboard-adjacent affordance consistent with
- * the rest of the shell.
- */
@Composable
-fun SearchScreen(viewModel: ShellSearchViewModel) {
+fun SearchScreen(
+ viewModel: ShellSearchViewModel,
+ catalogViewModel: RecipeCatalogViewModel,
+ catalogGridState: LazyGridState,
+) {
val state by viewModel.state.collectAsStateWithLifecycle()
+ val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
Box(
modifier =
@@ -47,26 +38,27 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .windowInsetsPadding(WindowInsets.statusBars)
- .padding(top = RecipeTheme.spacing.xl),
- ) {
- if (state.isFocused) {
+ if (state.isFocused) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .padding(top = RecipeTheme.spacing.xl),
+ ) {
EmptyState(
icon = Lucide.Search,
title = stringResource(Res.string.search_screen_empty_results_title),
subtitle = stringResource(Res.string.search_screen_empty_results_subtitle),
)
- } else {
- EmptyState(
- icon = Lucide.Search,
- title = stringResource(Res.string.search_screen_curated_title),
- subtitle = stringResource(Res.string.search_screen_curated_subtitle),
- )
}
+ } else {
+ RecipeCatalogGrid(
+ state = catalogState,
+ onRecipeClick = {},
+ gridState = catalogGridState,
+ modifier = Modifier.fillMaxSize(),
+ )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt
new file mode 100644
index 0000000..e2ff51a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCardUi.kt
@@ -0,0 +1,8 @@
+package dev.ulfrx.recipe.ui.screens.search.catalog
+
+data class RecipeCardUi(
+ val id: String,
+ val title: String,
+ val minutes: Int,
+ val kcal: Int,
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt
new file mode 100644
index 0000000..07a82bc
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogGrid.kt
@@ -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.minutes),
+ )
+ 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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogState.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogState.kt
new file mode 100644
index 0000000..b5e555d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogState.kt
@@ -0,0 +1,5 @@
+package dev.ulfrx.recipe.ui.screens.search.catalog
+
+data class RecipeCatalogState(
+ val cards: List = emptyList(),
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogViewModel.kt
new file mode 100644
index 0000000..7d16667
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/RecipeCatalogViewModel.kt
@@ -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 = _state.asStateFlow()
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt
new file mode 100644
index 0000000..9feddea
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/catalog/SampleRecipeCatalog.kt
@@ -0,0 +1,95 @@
+package dev.ulfrx.recipe.ui.screens.search.catalog
+
+internal val sampleRecipeCatalogCards: List =
+ listOf(
+ RecipeCardUi(
+ id = "rcp_nalesniki",
+ title = "Naleśniki z twarogiem",
+ minutes = 25,
+ kcal = 320,
+ ),
+ RecipeCardUi(
+ id = "rcp_owsianka",
+ title = "Owsianka z owocami i orzechami",
+ minutes = 10,
+ kcal = 280,
+ ),
+ RecipeCardUi(
+ id = "rcp_spaghetti",
+ title = "Spaghetti bolognese",
+ minutes = 40,
+ kcal = 540,
+ ),
+ RecipeCardUi(
+ id = "rcp_pierogi",
+ title = "Pierogi ruskie",
+ minutes = 90,
+ kcal = 460,
+ ),
+ RecipeCardUi(
+ id = "rcp_kanapka_awokado",
+ title = "Kanapka z awokado i jajkiem",
+ minutes = 5,
+ kcal = 210,
+ ),
+ RecipeCardUi(
+ id = "rcp_schabowy",
+ title = "Schabowy z ziemniakami",
+ minutes = 60,
+ kcal = 720,
+ ),
+ RecipeCardUi(
+ id = "rcp_salatka_grecka",
+ title = "Sałatka grecka",
+ minutes = 15,
+ kcal = 310,
+ ),
+ RecipeCardUi(
+ id = "rcp_pomidorowa",
+ title = "Zupa pomidorowa z ryżem",
+ minutes = 35,
+ kcal = 240,
+ ),
+ RecipeCardUi(
+ id = "rcp_kurczak_curry",
+ title = "Kurczak curry z ryżem basmati",
+ minutes = 45,
+ kcal = 580,
+ ),
+ RecipeCardUi(
+ id = "rcp_jajecznica",
+ title = "Jajecznica na maśle ze szczypiorkiem",
+ minutes = 8,
+ kcal = 290,
+ ),
+ RecipeCardUi(
+ id = "rcp_risotto",
+ title = "Risotto z grzybami leśnymi",
+ minutes = 35,
+ kcal = 470,
+ ),
+ RecipeCardUi(
+ id = "rcp_tortilla",
+ title = "Tortilla z kurczakiem i warzywami",
+ minutes = 20,
+ kcal = 430,
+ ),
+ RecipeCardUi(
+ id = "rcp_smoothie",
+ title = "Smoothie bananowo-szpinakowe",
+ minutes = 5,
+ kcal = 180,
+ ),
+ RecipeCardUi(
+ id = "rcp_losos",
+ title = "Łosoś pieczony z brokułami",
+ minutes = 30,
+ kcal = 510,
+ ),
+ RecipeCardUi(
+ id = "rcp_nadziewane_papryki",
+ title = "Papryki nadziewane kaszą i warzywami",
+ minutes = 55,
+ kcal = 390,
+ ),
+ )
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
index d2986d3..3af5c98 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
@@ -9,6 +9,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -24,6 +25,7 @@ import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
+import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel
@@ -32,6 +34,8 @@ import org.koin.compose.viewmodel.koinViewModel
fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel()
+ val catalogVm: RecipeCatalogViewModel = koinViewModel()
+ val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState()
@@ -56,7 +60,11 @@ fun AppShell(modifier: Modifier = Modifier) {
label = "AppShell body",
) { searchOpen ->
if (searchOpen) {
- SearchScreen(viewModel = searchVm)
+ SearchScreen(
+ viewModel = searchVm,
+ catalogViewModel = catalogVm,
+ catalogGridState = catalogGridState,
+ )
} else {
RootNavDisplay(
navigator = navigator,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt
index 86c11eb..a99f40b 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt
@@ -104,13 +104,14 @@ private fun DockBarExpanded(
}
},
) {
- val anim = rememberDockOverlayAnimations(
- pressState = pressState,
- activeIndex = activeIndex,
- tabBounds = tabBounds,
- dockWidthPx = dockWidthPx,
- density = LocalDensity.current,
- )
+ val anim =
+ rememberDockOverlayAnimations(
+ pressState = pressState,
+ activeIndex = activeIndex,
+ tabBounds = tabBounds,
+ dockWidthPx = dockWidthPx,
+ density = LocalDensity.current,
+ )
DockSubstrate(cornerRadius = height / 2)
DockActiveIndicatorLayer(
activeIndex = activeIndex,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt
index 61a0d46..63cea57 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt
@@ -28,11 +28,11 @@ import kotlinx.coroutines.launch
import kotlin.math.abs
private val PressOverlayBleed = 4.dp
-private const val SlideOutwardStiffness = Spring.StiffnessMediumLow
-private const val SlideSettleStiffness = Spring.StiffnessHigh
-private const val OverlayFadeInDurationMs = 120
-private const val OverlayFadeOutDurationMs = 40
-private const val SettleEpsilonPx = 0.5f
+private const val SLIDE_OUTWARD_STIFFNESS = Spring.StiffnessMediumLow
+private const val SLIDE_SETTLE_STIFFNESS = Spring.StiffnessHigh
+private const val OVERLAY_FADE_IN_DURATION_MS = 120
+private const val OVERLAY_FADE_OUT_DURATION_MS = 40
+private const val SETTLE_EPSILON_PX = 0.5f
internal data class DockOverlayAnimations(
val overlayCenterX: Float,
@@ -72,23 +72,27 @@ internal fun rememberDockOverlayAnimations(
activeCenterX,
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
- stiffness = SlideSettleStiffness,
- visibilityThreshold = SettleEpsilonPx,
+ stiffness = SLIDE_SETTLE_STIFFNESS,
+ visibilityThreshold = SETTLE_EPSILON_PX,
),
)
}
+
!wasPressed -> {
wasPressed = true
centerAnim.animateTo(
clampedPressX,
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
- stiffness = SlideOutwardStiffness,
- visibilityThreshold = SettleEpsilonPx,
+ stiffness = SLIDE_OUTWARD_STIFFNESS,
+ visibilityThreshold = SETTLE_EPSILON_PX,
),
)
}
- else -> centerAnim.snapTo(clampedPressX)
+
+ else -> {
+ centerAnim.snapTo(clampedPressX)
+ }
}
}
@@ -101,34 +105,35 @@ internal fun rememberDockOverlayAnimations(
activeAlphaAnim.snapTo(0f)
overlayAlphaAnim.animateTo(
targetValue = 1f,
- animationSpec = tween(OverlayFadeInDurationMs, easing = FastOutSlowInEasing),
+ animationSpec = tween(OVERLAY_FADE_IN_DURATION_MS, easing = FastOutSlowInEasing),
)
} else {
if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect
releaseSlideStartX = centerAnim.value
if (overlayAlphaAnim.value < 1f) {
- val tailMs = ((1f - overlayAlphaAnim.value) * OverlayFadeInDurationMs)
- .toInt()
- .coerceAtLeast(0)
+ val tailMs =
+ ((1f - overlayAlphaAnim.value) * OVERLAY_FADE_IN_DURATION_MS)
+ .toInt()
+ .coerceAtLeast(0)
if (tailMs > 0) {
overlayAlphaAnim.animateTo(1f, tween(tailMs, easing = FastOutSlowInEasing))
}
}
snapshotFlow {
!centerAnim.isRunning &&
- abs(centerAnim.value - activeCenterXState.value) < SettleEpsilonPx
+ abs(centerAnim.value - activeCenterXState.value) < SETTLE_EPSILON_PX
}.first { it }
coroutineScope {
launch {
overlayAlphaAnim.animateTo(
targetValue = 0f,
- animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing),
+ animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
)
}
launch {
activeAlphaAnim.animateTo(
targetValue = 1f,
- animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing),
+ animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
)
}
}
@@ -136,17 +141,21 @@ internal fun rememberDockOverlayAnimations(
}
}
- val releaseSlideProgress = run {
- val start = releaseSlideStartX
- if (start == null) {
- 0f
- } else {
- val target = activeCenterXState.value
- val total = abs(target - start)
- if (total < 1f) 0f
- else (abs(centerAnim.value - start) / total).coerceIn(0f, 1f)
+ val releaseSlideProgress =
+ run {
+ val start = releaseSlideStartX
+ if (start == null) {
+ 0f
+ } else {
+ val target = activeCenterXState.value
+ val total = abs(target - start)
+ if (total < 1f) {
+ 0f
+ } else {
+ (abs(centerAnim.value - start) / total).coerceIn(0f, 1f)
+ }
+ }
}
- }
val overlayPeakProgress = overlayAlphaAnim.value * (1f - releaseSlideProgress)
return DockOverlayAnimations(
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt
index 95e130b..7ee0c5b 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt
@@ -13,18 +13,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.util.lerp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
+import androidx.compose.ui.util.lerp
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlin.math.roundToInt
private val PressOverlayVerticalInset = 0.dp
private val ActiveIndicatorVerticalInset = 5.dp
-private const val PressOverlayScale = 1.22f
+private const val PRESS_OVERLAY_SCALE = 1.22f
@Composable
internal fun DockSubstrate(cornerRadius: Dp) {
@@ -49,16 +49,17 @@ internal fun DockActiveIndicatorLayer(
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
Box(
- modifier = Modifier
- .offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
- .width(with(density) { bbox.widthPx.toDp() })
- .fillMaxHeight()
- .padding(vertical = ActiveIndicatorVerticalInset)
- .alpha(alpha)
- .background(
- color = RecipeTheme.colors.chromeActive,
- shape = RoundedCornerShape(50),
- ),
+ modifier =
+ Modifier
+ .offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
+ .width(with(density) { bbox.widthPx.toDp() })
+ .fillMaxHeight()
+ .padding(vertical = ActiveIndicatorVerticalInset)
+ .alpha(alpha)
+ .background(
+ color = RecipeTheme.colors.chromeActive,
+ shape = RoundedCornerShape(50),
+ ),
)
}
@@ -77,22 +78,22 @@ internal fun DockPressOverlayLayer(
val dockHeightPx = with(density) { dockHeight.toPx() }
val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() }
val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f
- val scaleX = lerp(1f, PressOverlayScale, overlayPeakProgress)
- val scaleY = lerp(activeStartScaleY, PressOverlayScale, overlayPeakProgress)
+ val scaleX = lerp(1f, PRESS_OVERLAY_SCALE, overlayPeakProgress)
+ val scaleY = lerp(activeStartScaleY, PRESS_OVERLAY_SCALE, overlayPeakProgress)
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
val leftPx = overlayCenterX - overlayWidthPx / 2f
GlassSurface(
- modifier = Modifier
- .offset { IntOffset(leftPx.roundToInt(), 0) }
- .width(with(density) { overlayWidthPx.toDp() })
- .fillMaxHeight()
- .padding(vertical = PressOverlayVerticalInset)
- .graphicsLayer {
- this.scaleX = scaleX
- this.scaleY = scaleY
- }
- .alpha(overlayAlpha),
+ modifier =
+ Modifier
+ .offset { IntOffset(leftPx.roundToInt(), 0) }
+ .width(with(density) { overlayWidthPx.toDp() })
+ .fillMaxHeight()
+ .padding(vertical = PressOverlayVerticalInset)
+ .graphicsLayer {
+ this.scaleX = scaleX
+ this.scaleY = scaleY
+ }.alpha(overlayAlpha),
cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress,
tint = RecipeTheme.colors.surfaceGlassOverlay,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt
index 2ba9508..8773de3 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt
@@ -33,8 +33,8 @@ import kotlin.math.roundToInt
private val DockTabIconSize = 18.dp
private val DockTabIconLabelGap = 2.dp
-private const val DockTabLabelFontSizeSp = 11
-private const val DockTabLabelLineHeightSp = 13
+private const val DOCK_TAB_LABEL_FONT_SIZE_SP = 11
+private const val DOCK_TAB_LABEL_LINE_HEIGHT_SP = 13
@Composable
internal fun DockTabRow(
@@ -53,30 +53,32 @@ internal fun DockTabRow(
) {
destinations.forEachIndexed { index, destination ->
val cellBounds = tabBounds[index]
- val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) {
- val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
- val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
- bbox.centerPx - cellCenterX
- } else {
- 0f
- }
+ val contentOffsetPx =
+ if (cellBounds != null && dockWidthPx > 0f) {
+ val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
+ val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
+ bbox.centerPx - cellCenterX
+ } else {
+ 0f
+ }
DockTabItem(
destination = destination,
isActive = index == activeIndex,
contentOffsetPx = contentOffsetPx,
onSelect = { onTabSelectFromA11y(destination) },
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- .onGloballyPositioned { coords ->
- onTabBoundsChange(
- index,
- TabBounds(
- offsetXPx = coords.positionInParent().x,
- widthPx = coords.size.width.toFloat(),
- ),
- )
- },
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .onGloballyPositioned { coords ->
+ onTabBoundsChange(
+ index,
+ TabBounds(
+ offsetXPx = coords.positionInParent().x,
+ widthPx = coords.size.width.toFloat(),
+ ),
+ )
+ },
)
}
}
@@ -94,15 +96,16 @@ private fun DockTabItem(
val a11yLabel = if (isActive) "$label, aktywna" else label
val tint = RecipeTheme.colors.content
Box(
- modifier = modifier.semantics {
- role = Role.Tab
- selected = isActive
- contentDescription = a11yLabel
- onClick {
- onSelect()
- true
- }
- },
+ modifier =
+ modifier.semantics {
+ role = Role.Tab
+ selected = isActive
+ contentDescription = a11yLabel
+ onClick {
+ onSelect()
+ true
+ }
+ },
contentAlignment = Alignment.Center,
) {
Column(
@@ -119,11 +122,12 @@ private fun DockTabItem(
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
BasicText(
text = label,
- style = RecipeTheme.typography.label.copy(
- color = tint,
- fontSize = DockTabLabelFontSizeSp.sp,
- lineHeight = DockTabLabelLineHeightSp.sp,
- ),
+ style =
+ RecipeTheme.typography.label.copy(
+ color = tint,
+ fontSize = DOCK_TAB_LABEL_FONT_SIZE_SP.sp,
+ lineHeight = DOCK_TAB_LABEL_LINE_HEIGHT_SP.sp,
+ ),
)
}
}