Add recipe catalog view
This commit is contained in:
@@ -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<HomeViewModel>()
|
||||
viewModel<PlannerViewModel>()
|
||||
viewModel<PantryViewModel>()
|
||||
viewModel<ShoppingViewModel>()
|
||||
|
||||
// Shell-wide search VM — single global state machine (closed / open
|
||||
// unfocused / open focused) shared by the SearchScreen body and the
|
||||
// SearchPillRow chrome. Per-tab SearchViewModels were retired when search
|
||||
// moved from per-tab inline overlay to a shell-level destination.
|
||||
viewModel<ShellSearchViewModel>()
|
||||
viewModel<RecipeCatalogViewModel>()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user