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, + ), ) } }