Add recipe catalog view

This commit is contained in:
2026-05-22 15:22:33 +02:00
parent ae4186d9fa
commit 6d38b8b775
14 changed files with 486 additions and 138 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

View File

@@ -22,12 +22,14 @@
<!-- 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 — 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>

View File

@@ -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.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.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>()
} }

View File

@@ -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,22 @@ 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.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,
catalogGridState: LazyGridState,
) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
Box( Box(
modifier = modifier =
@@ -47,6 +38,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 +46,19 @@ 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 = {},
subtitle = stringResource(Res.string.search_screen_curated_subtitle), gridState = catalogGridState,
modifier = Modifier.fillMaxSize(),
) )
} }
} }
} }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
package dev.ulfrx.recipe.ui.screens.search.catalog
internal val sampleRecipeCatalogCards: List<RecipeCardUi> =
listOf(
RecipeCardUi(
id = "rcp_nalesniki",
title = "Naleśniki z twarogiem",
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,
),
)

View File

@@ -9,6 +9,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -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.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.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -32,6 +34,8 @@ 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 catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle() val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState() val backdropState = rememberGlassBackdropState()
@@ -56,7 +60,11 @@ 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,
catalogGridState = catalogGridState,
)
} else { } else {
RootNavDisplay( RootNavDisplay(
navigator = navigator, navigator = navigator,

View File

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

View File

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

View File

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

View File

@@ -33,8 +33,8 @@ import kotlin.math.roundToInt
private val DockTabIconSize = 18.dp private val DockTabIconSize = 18.dp
private val DockTabIconLabelGap = 2.dp private val DockTabIconLabelGap = 2.dp
private const val DockTabLabelFontSizeSp = 11 private const val DOCK_TAB_LABEL_FONT_SIZE_SP = 11
private const val DockTabLabelLineHeightSp = 13 private const val DOCK_TAB_LABEL_LINE_HEIGHT_SP = 13
@Composable @Composable
internal fun DockTabRow( internal fun DockTabRow(
@@ -53,7 +53,8 @@ internal fun DockTabRow(
) { ) {
destinations.forEachIndexed { index, destination -> destinations.forEachIndexed { index, destination ->
val cellBounds = tabBounds[index] val cellBounds = tabBounds[index]
val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) { val contentOffsetPx =
if (cellBounds != null && dockWidthPx > 0f) {
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density) val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
bbox.centerPx - cellCenterX bbox.centerPx - cellCenterX
@@ -65,7 +66,8 @@ internal fun DockTabRow(
isActive = index == activeIndex, isActive = index == activeIndex,
contentOffsetPx = contentOffsetPx, contentOffsetPx = contentOffsetPx,
onSelect = { onTabSelectFromA11y(destination) }, onSelect = { onTabSelectFromA11y(destination) },
modifier = Modifier modifier =
Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
@@ -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,
), ),
) )
} }