Compare commits

...

7 Commits

Author SHA1 Message Date
f1e391ccda Adjust dock overlay 2026-05-18 21:54:42 +02:00
488509db06 Adjust dock overlay animation 2026-05-18 20:11:33 +02:00
ab1630a06b Add calendar in PlannerScreen 2026-05-18 17:02:34 +02:00
fb00df856a Reorganise dockbar code 2026-05-17 22:23:24 +02:00
8eda4b04ee Add home screen 2026-05-17 20:44:25 +02:00
8700d197f0 Search/catalog planning notes 2026-05-16 23:44:55 +02:00
ac5bfbc423 Rework on the dockbar 2026-05-16 23:14:06 +02:00
45 changed files with 1884 additions and 1059 deletions

View File

@@ -0,0 +1,39 @@
---
title: Nawigacja — search jako katalog przepisów + nowy tab Home
date: 2026-05-16
context: Eksploracja zmiany struktury nawigacji przed Phase 2.1 (app shell/navigation/search) i Phase 5 (Recipe catalog)
---
# Nawigacja — search jako katalog przepisów + nowy tab Home
## Decyzje kierunkowe
1. **Usuwamy tab Przepisy z docka.** Search button (siostra docka, zgodnie z `project_dock_layout.md`) staje się wejściem do bogatego ekranu-katalogu z kategoriami, browse i filtrem po składnikach. Wpisywanie tekstu w pole to drugorzędna ścieżka — główna interakcja to *przeglądanie*, nie *known-item search*.
2. **Dodajemy tab Home przed Planerem.** Charakter: landing z podsumowaniami i propozycjami — ma *coś podpowiadać* użytkownikowi, a nie być pasywnym ekranem powitalnym. Konkretny scope widgetów odłożony (patrz seed `home-tab-content.md`).
## Driver
Rezerwa slotów w docku na przyszłe funkcje — m.in. "co mam w lodówce → przepis", dodawanie przepisów, potencjalnie inne discovery flows. Bezpośrednia inspiracja: Apple Music (search jako siostra/przycisk obok zakładek, otwierający kategorie zanim użytkownik cokolwiek wpisze).
## Napięcie do rozwiązania w Phase 2.1
Obecny model search (z memory `project_dock_layout.md`) to **overlay w 3 stanach** (closed / open-unfocused / open-focused) z zablokowanym backiem. Nowy model wymaga **pełnej destynacji** z back-stackiem: browse kategorii → szczegóły przepisu → back do listy → filtr → itd.
Trzy ścieżki do rozważenia w designie:
- **A.** Overlay rozrasta się płynnie w pełen ekran (animacja przejścia stan otwarty-niezaogniskowany → destynacja); back-stack aktywuje się dopiero po pierwszej akcji navigacyjnej.
- **B.** Search button przestaje być overlayem; od razu nawiguje do dedykowanej destynacji (ekran katalogu z search bar na górze). Prościej, ale tracimy "lekkość" overlaya na innych ekranach.
- **C.** Hybryda: overlay pozostaje dla quick-search z każdego ekranu (wpisz frazę → wyniki), ale tap w "browse categories" wewnątrz overlaya nawiguje do pełnej destynacji.
Decyzja do podjęcia w `/gsd-discuss-phase 2.1` lub w sketch passie.
## Sprawy zaparkowane
- Umiejscowienie akcji "+Dodaj przepis" (Home / toolbar app shell / ekran katalogu / ustawienia). Niska częstotliwość użycia — może być dwa-tapy głębiej. Patrz todo `decide-add-recipe-placement.md`.
- Konkretny scope widgetów Home. Patrz seed `home-tab-content.md`.
## Wpływ na roadmapę
- **Phase 2.1 (app shell/navigation/search)** — zmienia się definicja search button i layout docka (4-tab → 5-tab: Home, Planer, Spiżarnia, Zakupy + search button jako sibling, bez taba Przepisy).
- **Phase 5 (Recipe catalog)** — ekran katalogu nie ma własnego taba; wejście wyłącznie przez search button. Pozostała funkcjonalność (lista, kategorie, szczegóły, filtry) bez zmian.
- **Phase 10 (UI chrome polish)** — Home prawdopodobnie domyka się tutaj wizualnie.

View File

@@ -0,0 +1,36 @@
---
title: Scope widgetów tab Home
trigger_condition: Przed rozpoczęciem Phase 10 (UI chrome polish) lub gdy Phase 2.1 (app shell/navigation/search) wchodzi w fazę projektowania ekranów
planted_date: 2026-05-16
---
# Scope widgetów tab Home
## Kontekst
Decyzja kierunkowa (patrz `notes/nav-search-as-catalog.md`): dodajemy tab Home przed Planerem. Charakter: landing z podpowiedziami i podsumowaniami — *coś musi mówić użytkownikowi*, nie być pustym powitaniem.
## Otwarte pytanie
Co konkretnie pokazujemy na Home, żeby:
- nie dublować funkcji Planera/Spiżarni/Zakupów,
- być realną wartością przy pierwszym otwarciu appki rano,
- nie spuchnąć do nieczytelnego dashboardu z 8 sekcjami.
## Kandydaci do rozważenia
- **"Co jemy dziś / jutro"** — wycinek planera w formie karty; tap → Planer otwarty na danym dniu.
- **"Czego brakuje do najbliższych posiłków"** — shortcut do brakujących składników; tap → Zakupy / Spiżarnia.
- **"Propozycje przepisów"** — sugestie oparte na: ostatnio dodane, sezonowe, "na podstawie tego co masz w lodówce" (przyszła funkcja).
- **"Szybkie akcje"** — m.in. potencjalne miejsce na "+Dodaj przepis".
- **Powitanie / pasywny landing** — odrzucone we wstępnej dyskusji (Home ma podpowiadać).
## Pytania pomocnicze do rozstrzygnięcia później
- Czy Home jest *statyczny* (zawsze te same sekcje), czy *adaptacyjny* (sekcje pojawiają się zależnie od stanu — np. "brakuje produktów" tylko gdy faktycznie brakuje)?
- Czy Home wystarczy na MVP w minimalnej formie (1-2 sekcje), czy czekamy z nim do momentu, gdy mamy więcej funkcji do podsumowania?
- Czy Home jest też miejscem na onboarding (przy pierwszym uruchomieniu)?
## Rekomendowana ścieżka
`/gsd-sketch` na początku Phase 2.1 lub Phase 10 — Home to wyraźny przypadek "what should this look like / feel" z wieloma stanami do wyklikania w HTML.

View File

@@ -0,0 +1,29 @@
---
title: Zdecyduj gdzie ląduje akcja "+Dodaj przepis"
date: 2026-05-16
priority: medium
blocks: Phase 5 (Recipe catalog)
---
# Zdecyduj gdzie ląduje akcja "+Dodaj przepis"
## Kontekst
Po decyzji o usunięciu taba Przepisy (patrz `notes/nav-search-as-catalog.md`) nie ma już oczywistego miejsca na akcję dodawania przepisu. Dodawanie ma być raczej *dodatkiem*, nie codzienną interakcją — może być dwa-tapy głębiej.
## Kandydaci
- **Home** — naturalne miejsce na "szybkie akcje"; wymaga że Home już istnieje i ma miejsce na taki shortcut.
- **Toolbar app shell** — globalny "+" obok search button; zawsze pod ręką, ale konkuruje o przestrzeń z search i ewentualnymi przyszłymi przyciskami.
- **Ekran katalogu (otwierany przez search)** — semantycznie sensowne (jest o przepisach), ale dziwne że użytkownik musi przejść przez search żeby coś dodać.
- **Ustawienia** — najbardziej ukryte; OK jeśli dodawanie jest skrajnie rzadkie.
## Kryteria decyzyjne
- Częstotliwość użycia (im rzadsze, tym głębiej można schować).
- Czy "+Dodaj przepis" to akcja związana z *katalogiem* (sensowne w toolbarze katalogu) czy *zarządzaniem treścią* (sensowne w ustawieniach / Home jako command center).
- Spójność z innymi akcjami tworzenia, które mogą dojść w v2 (np. dodawanie produktu do spiżarni — gdzie ona ląduje?).
## Termin
Rozstrzygnąć przed `/gsd-plan-phase 5` (Recipe catalog). Można też w `/gsd-discuss-phase 2.1` jeśli decyzja wpływa na layout app shell.

View File

@@ -89,6 +89,7 @@ kotlin {
implementation(libs.ktor.clientLogging)
implementation(libs.ktor.serializationKotlinxJsonMpp)
implementation(libs.kotlinx.serializationJson)
implementation(libs.kotlinx.datetime)
implementation(libs.multiplatform.settings)
implementation(libs.lokksmith.compose)
implementation(libs.navigation3.ui)

View File

@@ -14,8 +14,8 @@
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
<!-- Phase 2.1 — App shell navigation tab labels (UI-03, CONTEXT D-03) -->
<string name="shell_tab_home">Start</string>
<string name="shell_tab_planner">Planer</string>
<string name="shell_tab_recipes">Przepisy</string>
<string name="shell_tab_pantry">Spiżarnia</string>
<string name="shell_tab_shopping">Zakupy</string>
@@ -30,15 +30,17 @@
<!-- 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_close_a11y">Zamknij wyszukiwanie</string>
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
<string name="search_clear_a11y">Wyczyść</string>
<!-- Phase 2.1 — Dock a11y -->
<string name="dock_expand_a11y">Rozwiń pasek nawigacji</string>
<!-- Phase 2.1 — Empty-state copy (UI-09, CONTEXT D-10/D-11/D-12) -->
<string name="empty_home_title">Tu pojawi się Twój dzień</string>
<string name="empty_home_subtitle">Wkrótce zobaczysz tu podsumowania i propozycje.</string>
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
<string name="empty_recipes_title">Tu pojawi się Twoja książka kucharska</string>
<string name="empty_recipes_subtitle">Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.</string>
<string name="empty_pantry_title">Spiżarnia jest jeszcze pusta</string>
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>

View File

@@ -1,8 +1,8 @@
package dev.ulfrx.recipe.di
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.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.dsl.module
@@ -14,8 +14,8 @@ val shellModule =
// 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<RecipesViewModel>()
viewModel<PantryViewModel>()
viewModel<ShoppingViewModel>()

View File

@@ -1,47 +1,33 @@
package dev.ulfrx.recipe.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.composables.icons.lucide.BookOpenText
import com.composables.icons.lucide.CalendarDays
import com.composables.icons.lucide.House
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Package
import com.composables.icons.lucide.ShoppingCart
import org.jetbrains.compose.resources.StringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.shell_tab_home
import recipe.composeapp.generated.resources.shell_tab_pantry
import recipe.composeapp.generated.resources.shell_tab_planner
import recipe.composeapp.generated.resources.shell_tab_recipes
import recipe.composeapp.generated.resources.shell_tab_shopping
/**
* The 4 bottom-bar destinations in leftright order per CONTEXT D-03:
* Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
* default landing tab CONTEXT D-03 departs from REQUIREMENTS' literal
* listing order, which research confirmed is non-binding.
*
* Each destination carries its tab's *root* [Screen] (e.g. [Screen.Planner.Home])
* so the shell's [TabNavigator] knows where each tab's back stack starts.
*
* Search is a shell-wide affordance (see
* [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel]) it lives outside
* the tab destinations entirely. This enum is intentionally minimal: route +
* label + icon, nothing about feature affordances.
*/
enum class BottomBarDestination(
enum class DockDestination(
val startDestination: Screen,
val labelRes: StringResource,
val icon: ImageVector,
) {
Home(
startDestination = Screen.Home.Root,
labelRes = Res.string.shell_tab_home,
icon = Lucide.House,
),
Planner(
startDestination = Screen.Planner.Home,
labelRes = Res.string.shell_tab_planner,
icon = Lucide.CalendarDays,
),
Recipes(
startDestination = Screen.Recipes.Home,
labelRes = Res.string.shell_tab_recipes,
icon = Lucide.BookOpenText,
),
Pantry(
startDestination = Screen.Pantry.Home,
labelRes = Res.string.shell_tab_pantry,
@@ -55,7 +41,6 @@ enum class BottomBarDestination(
;
companion object {
/** Default landing tab — CONTEXT D-03. */
val Default: BottomBarDestination = Planner
val Default: DockDestination = Home
}
}

View File

@@ -10,12 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import dev.ulfrx.recipe.ui.screens.home.HomeScreen
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.compose.viewmodel.koinViewModel
@@ -71,24 +71,25 @@ fun RootNavDisplay(
backStack = navigator.backStackFor(tab),
modifier = Modifier.fillMaxSize(),
onBack = { navigator.goBack(tab) },
entryProvider = entryProvider {
entry<Screen.Planner.Home> {
val vm: PlannerViewModel = koinViewModel()
PlannerScreen(viewModel = vm)
}
entry<Screen.Recipes.Home> {
val vm: RecipesViewModel = koinViewModel()
RecipesScreen(viewModel = vm)
}
entry<Screen.Pantry.Home> {
val vm: PantryViewModel = koinViewModel()
PantryScreen(viewModel = vm)
}
entry<Screen.Shopping.Home> {
val vm: ShoppingViewModel = koinViewModel()
ShoppingScreen(viewModel = vm)
}
},
entryProvider =
entryProvider {
entry<Screen.Home.Root> {
val vm: HomeViewModel = koinViewModel()
HomeScreen(viewModel = vm)
}
entry<Screen.Planner.Home> {
val vm: PlannerViewModel = koinViewModel()
PlannerScreen(viewModel = vm)
}
entry<Screen.Pantry.Home> {
val vm: PantryViewModel = koinViewModel()
PantryScreen(viewModel = vm)
}
entry<Screen.Shopping.Home> {
val vm: ShoppingViewModel = koinViewModel()
ShoppingScreen(viewModel = vm)
}
},
)
}
}

View File

@@ -7,22 +7,25 @@ import kotlinx.serialization.Serializable
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
* back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration).
*
* Screens are grouped by tab so future detail destinations (Phase 5+) slot in
* without polluting the top-level namespace — e.g. `Screen.Recipes.Detail(id)`.
* The grouping is purely a code-organisation convenience; Nav 3 treats each
* leaf as an independent NavKey regardless of nesting.
* Screens are grouped by tab so future detail destinations slot in without
* polluting the top-level namespace — e.g. `Screen.Pantry.Detail(id)`. The
* grouping is purely a code-organisation convenience; Nav 3 treats each leaf as
* an independent NavKey regardless of nesting.
*
* The Recipes catalog has no own tab — it is reached via the shell-wide search
* destination (see `ShellSearchViewModel`).
*/
sealed interface Screen : NavKey {
sealed interface Home : Screen {
@Serializable
data object Root : Home
}
sealed interface Planner : Screen {
@Serializable
data object Home : Planner
}
sealed interface Recipes : Screen {
@Serializable
data object Home : Recipes
}
sealed interface Pantry : Screen {
@Serializable
data object Home : Pantry

View File

@@ -2,28 +2,27 @@ package dev.ulfrx.recipe.navigation
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf
@Stable
class TabNavigator(
initialTab: BottomBarDestination = BottomBarDestination.Default,
initialTab: DockDestination = DockDestination.Default,
) {
private val backStacks: Map<BottomBarDestination, SnapshotStateList<Screen>> =
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
private val backStacks: Map<DockDestination, SnapshotStateList<Screen>> =
DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
var activeTab: DockDestination by mutableStateOf(initialTab)
private set
val activeBackStack: SnapshotStateList<Screen>
get() = backStacks.getValue(activeTab)
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
backStacks.getValue(tab)
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = backStacks.getValue(tab)
fun selectTab(tab: BottomBarDestination) {
fun selectTab(tab: DockDestination) {
if (tab == activeTab) {
popToRoot(tab)
} else {
@@ -35,14 +34,14 @@ class TabNavigator(
activeBackStack.add(screen)
}
fun goBack(tab: BottomBarDestination = activeTab) {
fun goBack(tab: DockDestination = activeTab) {
val stack = backStacks.getValue(tab)
if (stack.size > 1) {
stack.removeAt(stack.lastIndex)
}
}
private fun popToRoot(tab: BottomBarDestination) {
private fun popToRoot(tab: DockDestination) {
val stack = backStacks.getValue(tab)
while (stack.size > 1) {
stack.removeAt(stack.lastIndex)

View File

@@ -0,0 +1,119 @@
package dev.ulfrx.recipe.ui.components.calendar
import kotlinx.datetime.Clock
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
/** Today in the system time zone. */
fun todayInSystemTz(): LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
/** Monday-anchored start of the ISO week containing [date]. */
fun LocalDate.startOfWeekMonday(): LocalDate {
val diff = dayOfWeek.ordinal - DayOfWeek.MONDAY.ordinal
return this.minus(DatePeriod(days = diff))
}
/** First day of the month containing [date]. */
fun LocalDate.startOfMonth(): LocalDate = LocalDate(year, month, 1)
/**
* Returns 42 consecutive days starting from the Monday on/before the 1st of
* [anchor]'s month — i.e., the 6-week visible grid. Anchor's month always
* starts on the first row; trailing rows fill from the next month.
*/
fun monthGridDays(anchor: LocalDate): List<LocalDate> {
val gridStart = anchor.startOfMonth().startOfWeekMonday()
return List(42) { i -> gridStart.plus(DatePeriod(days = i)) }
}
/** Seven days starting from Monday of [anchor]'s week. */
fun weekStripDays(anchor: LocalDate): List<LocalDate> {
val start = anchor.startOfWeekMonday()
return List(7) { i -> start.plus(DatePeriod(days = i)) }
}
/** Formats the visible-period label rendered in the topbar pill. */
fun formatPeriodLabel(
mode: CalendarMode,
anchor: LocalDate,
locale: CalendarLocale,
): String =
when (mode) {
CalendarMode.Month -> {
"${locale.monthsLong[anchor.monthNumber - 1]} ${anchor.year}"
}
CalendarMode.Week -> {
val start = anchor.startOfWeekMonday()
val end = start.plus(DatePeriod(days = 6))
when {
start.year == end.year && start.monthNumber == end.monthNumber -> {
"${start.dayOfMonth}${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
start.year == end.year -> {
"${start.dayOfMonth} ${locale.monthsShort[start.monthNumber - 1]} " +
"${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
else -> {
"${start.dayOfMonth} ${locale.monthsShort[start.monthNumber - 1]} ${start.year} " +
"${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
}
}
}
/** True when [date] is inside the period visible at [anchor] under [mode]. */
fun isInVisiblePeriod(
date: LocalDate,
anchor: LocalDate,
mode: CalendarMode,
): Boolean =
when (mode) {
CalendarMode.Month -> {
date.year == anchor.year && date.monthNumber == anchor.monthNumber
}
CalendarMode.Week -> {
val start = anchor.startOfWeekMonday()
val end = start.plus(DatePeriod(days = 6))
date in start..end
}
}
/**
* Whole-unit offset between [a] and [b] under [mode] (signed b - a). Used to
* map between the surface's pager index and an anchor date.
*/
fun periodsBetween(
a: LocalDate,
b: LocalDate,
mode: CalendarMode,
): Int =
when (mode) {
CalendarMode.Month -> {
(b.year - a.year) * 12 + (b.monthNumber - a.monthNumber)
}
CalendarMode.Week -> {
val startDays = a.startOfWeekMonday().toEpochDays()
val endDays = b.startOfWeekMonday().toEpochDays()
(endDays - startDays) / 7
}
}
/** Advance [date] by [delta] units of [mode]. */
fun LocalDate.plusPeriods(
delta: Int,
mode: CalendarMode,
): LocalDate =
when (mode) {
CalendarMode.Month -> this.plus(DatePeriod(months = delta))
CalendarMode.Week -> this.plus(DatePeriod(days = delta * 7))
}

View File

@@ -0,0 +1,131 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
/**
* Single day cell — circle, optional outline ring for "today", optional fill
* for "selected", optional dot indicator below the date number. Disabled days
* render as a non-interactive box.
*/
@Composable
internal fun CalendarDayCell(
date: LocalDate,
state: DayState,
isSelected: Boolean,
isToday: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val baseColor = colors.content
val mutedColor = colors.contentMuted
val accent = colors.accent
val background: Color =
when {
isSelected -> accent.copy(alpha = 0.18f)
else -> Color.Transparent
}
val textColor: Color =
when {
state.disabled -> mutedColor.copy(alpha = 0.45f)
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
isSelected -> accent
else -> baseColor
}
val ringColor: Color =
when {
isSelected -> accent.copy(alpha = 0.55f)
isToday -> baseColor.copy(alpha = 0.35f)
else -> Color.Transparent
}
val cellModifier =
modifier
.height(36.dp)
.fillMaxWidth()
if (state.disabled) {
Box(modifier = cellModifier, contentAlignment = Alignment.Center) {
DayCellInner(
date = date,
textColor = textColor,
indicator = state.indicator,
indicatorColor = mutedColor.copy(alpha = 0.5f),
)
}
return
}
UnstyledButton(
onClick = onClick,
backgroundColor = background,
contentColor = textColor,
shape = CircleShape,
borderColor = ringColor,
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
modifier = cellModifier,
) {
DayCellInner(
date = date,
textColor = textColor,
indicator = state.indicator,
indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = 0.65f),
)
}
}
@Composable
private fun DayCellInner(
date: LocalDate,
textColor: Color,
indicator: Boolean,
indicatorColor: Color,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
BasicText(
text = date.dayOfMonth.toString(),
style =
RecipeTheme.typography.label.copy(
color = textColor,
fontWeight = FontWeight.SemiBold,
),
)
if (indicator) {
Spacer(modifier = Modifier.height(2.dp))
Box(
modifier =
Modifier
.size(4.dp)
.clip(CircleShape)
.background(indicatorColor),
)
}
}
}

View File

@@ -0,0 +1,121 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
private val DAY_SPACING = 4.dp
private val WEEK_SPACING = 4.dp
/** Weekday-letter header row. */
@Composable
internal fun WeekdayHeader(
locale: CalendarLocale,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
locale.weekdaysShort.forEach { label ->
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
BasicText(
text = label,
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
),
)
}
}
}
}
/**
* Seven-day Monday-first strip for [anchor]'s week. All days are in-period so
* the [DayState.dimmed] flag is never set by this composable itself.
*/
@Composable
internal fun WeekStrip(
anchor: LocalDate,
today: LocalDate,
dayState: (LocalDate) -> DayState,
isSelected: (LocalDate) -> Boolean,
onSelect: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val days = weekStripDays(anchor)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
days.forEach { day ->
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = dayState(day),
isSelected = isSelected(day),
isToday = day == today,
onClick = { onSelect(day) },
)
}
}
}
}
/**
* Fixed 6-week grid for [anchor]'s month. Adjacent-month days are auto-marked
* dimmed (caller's [dayState] does not need to set that flag for them).
*/
@Composable
internal fun MonthGrid(
anchor: LocalDate,
today: LocalDate,
dayState: (LocalDate) -> DayState,
isSelected: (LocalDate) -> Boolean,
onSelect: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val days = monthGridDays(anchor)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(WEEK_SPACING),
) {
for (week in 0 until 6) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
for (dayIdx in 0 until 7) {
val day = days[week * 7 + dayIdx]
val inMonth = day.monthNumber == anchor.monthNumber
val resolved = dayState(day)
val effective =
if (!inMonth) resolved.copy(dimmed = true) else resolved
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = effective,
isSelected = isSelected(day),
isToday = day == today,
onClick = { onSelect(day) },
)
}
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
/**
* Pill button showing the visible period label. Tapping jumps to today and
* selects it. Optional chevron at the end toggles week/month when [expandable]
* is set; the chevron is hidden otherwise so popup variants get a clean pill.
*/
@Composable
internal fun CalendarTopbar(
mode: CalendarMode,
anchor: LocalDate,
today: LocalDate,
selectedDate: LocalDate,
locale: CalendarLocale,
onJumpToToday: () -> Unit,
expandable: Boolean,
onToggleMode: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val onToday = selectedDate == today && isInVisiblePeriod(today, anchor, mode)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
UnstyledButton(
onClick = onJumpToToday,
enabled = !onToday,
backgroundColor = Color.Transparent,
contentColor = colors.content,
shape = CircleShape,
borderColor = colors.separator,
borderWidth = 1.dp,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
modifier = Modifier.defaultMinSize(minHeight = 32.dp),
) {
BasicText(
text = formatPeriodLabel(mode, anchor, locale),
style =
RecipeTheme.typography.label.copy(
color = if (onToday) colors.contentMuted else colors.content,
),
modifier = if (onToday) Modifier.alpha(0.6f) else Modifier,
)
}
if (expandable) {
Spacer(modifier = Modifier.size(8.dp))
UnstyledButton(
onClick = onToggleMode,
backgroundColor = Color.Transparent,
contentColor = colors.content,
shape = CircleShape,
borderColor = colors.separator,
borderWidth = 1.dp,
contentPadding = PaddingValues(6.dp),
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) {
UnstyledIcon(
imageVector = Lucide.ChevronDown,
contentDescription = null,
tint = colors.contentMuted,
modifier =
Modifier
.size(14.dp)
.rotate(if (mode == CalendarMode.Month) 180f else 0f),
)
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Immutable
/**
* Whether the calendar shows a single week strip or the full month grid.
* Planner uses both with a toggle; Pantry/Shopping popups stay on [Month].
*/
enum class CalendarMode { Week, Month }
/**
* Per-day visual modifiers resolved by the caller. Selection and "today"
* outline are handled by the surface itself and must not be set here.
*
* @param dimmed Day belongs to an adjacent month in the 6-week grid.
* @param disabled Day is non-interactive (e.g., past dates in Pantry).
* @param indicator Render a small dot under the date number (e.g., "has meal").
*/
@Immutable
data class DayState(
val dimmed: Boolean = false,
val disabled: Boolean = false,
val indicator: Boolean = false,
)
/**
* Localized strings for the calendar. Hardcoded to Polish in v1 (REQ-LOC-PL).
* Externalize to string resources when other locales arrive.
*/
@Immutable
data class CalendarLocale(
val weekdaysShort: List<String>,
val monthsLong: List<String>,
val monthsShort: List<String>,
) {
companion object {
val PL: CalendarLocale =
CalendarLocale(
weekdaysShort = listOf("pn", "wt", "śr", "cz", "pt", "so", "nd"),
monthsLong =
listOf(
"Styczeń",
"Luty",
"Marzec",
"Kwiecień",
"Maj",
"Czerwiec",
"Lipiec",
"Sierpień",
"Wrzesień",
"Październik",
"Listopad",
"Grudzień",
),
monthsShort =
listOf(
"sty",
"lut",
"mar",
"kwi",
"maj",
"cze",
"lip",
"sie",
"wrz",
"paź",
"lis",
"gru",
),
)
}
}

View File

@@ -0,0 +1,168 @@
package dev.ulfrx.recipe.ui.components.calendar
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.LocalDate
/**
* Reusable calendar surface for planner, pantry, and shopping. One swipe-able
* paged carousel of week strips or month grids, plus an optional chevron to
* toggle between the two modes.
*
* The composable is **controlled** — anchor/selection/mode live in the
* caller's state. The pager is local UI state and is re-keyed when [mode]
* changes (so the new origin date can be picked up safely).
*
* @param selectedDate Currently selected day. Defaults to the only highlight
* used by [isSelectedOverride]'s default impl. Tapping the topbar pill jumps
* here.
* @param today Used for the "today" outline ring; also the date the topbar
* jumps to when tapped.
* @param mode Whether to render week strips or month grids.
* @param onSelectDate Called when the user taps a day cell.
* @param onModeChange Called when the user taps the expand chevron.
* @param onVisibleAnchorChange Called when the user swipes to a new period.
* Receives an anchor inside the now-visible period. The caller usually
* updates [selectedDate] in response (see PlannerViewModel for the pattern).
* @param dayState Per-day visual modifiers (dimmed for adjacent-month days is
* added automatically by the month grid).
* @param isSelectedOverride Custom selection predicate. Pass for range
* selection; defaults to `date == selectedDate`.
* @param expandable When true, renders the chevron and supports mode toggle.
* Popup variants (pantry/shopping) set this to false.
*/
@Composable
fun SwipeableCalendar(
selectedDate: LocalDate,
today: LocalDate,
mode: CalendarMode,
onSelectDate: (LocalDate) -> Unit,
onModeChange: (CalendarMode) -> Unit,
onVisibleAnchorChange: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
dayState: (LocalDate) -> DayState = { DayState() },
isSelectedOverride: ((LocalDate) -> Boolean)? = null,
expandable: Boolean = true,
locale: CalendarLocale = CalendarLocale.PL,
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp),
) {
val isSelected: (LocalDate) -> Boolean =
isSelectedOverride ?: { it == selectedDate }
val currentOnAnchorChange by rememberUpdatedState(onVisibleAnchorChange)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
// Re-key the pager block on mode so we can pick a fresh origin from
// the currently-selected date. The pager state is local; the caller
// never needs to scroll it manually.
key(mode) {
val origin = remember { selectedDate }
val initialPage = remember { INITIAL_PAGE }
val pagerState = rememberPagerState(initialPage = initialPage) { PAGE_COUNT }
CalendarTopbar(
mode = mode,
anchor = origin.plusPeriods(pagerState.currentPage - initialPage, mode),
today = today,
selectedDate = selectedDate,
locale = locale,
onJumpToToday = { onSelectDate(today) },
expandable = expandable,
onToggleMode = {
onModeChange(
if (mode == CalendarMode.Month) CalendarMode.Week else CalendarMode.Month,
)
},
modifier = Modifier.padding(contentPadding),
)
// Bring the pager onto the page that contains [selectedDate]
// whenever it changes externally (e.g., tap "today" on the topbar
// or a fresh selection from the page we're already on).
LaunchedEffect(selectedDate) {
val target = initialPage + periodsBetween(origin, selectedDate, mode)
if (target != pagerState.currentPage) {
pagerState.animateScrollToPage(target)
}
}
// Report swipe-driven anchor changes upward so the caller can keep
// its own selection in sync (e.g., planner auto-follows the week).
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }
.distinctUntilChanged()
.collect { page ->
if (page == initialPage) return@collect
val anchor = origin.plusPeriods(page - initialPage, mode)
if (!isInVisiblePeriod(selectedDate, anchor, mode)) {
currentOnAnchorChange(anchor)
}
}
}
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
WeekdayHeader(locale = locale)
HorizontalPager(
state = pagerState,
pageSpacing = 0.dp,
flingBehavior =
PagerDefaults.flingBehavior(
state = pagerState,
),
modifier = Modifier.fillMaxWidth(),
) { page ->
val pageAnchor = origin.plusPeriods(page - initialPage, mode)
Box(modifier = Modifier.fillMaxWidth()) {
when (mode) {
CalendarMode.Week -> {
WeekStrip(
anchor = pageAnchor,
today = today,
dayState = dayState,
isSelected = isSelected,
onSelect = onSelectDate,
)
}
CalendarMode.Month -> {
MonthGrid(
anchor = pageAnchor,
today = today,
dayState = dayState,
isSelected = isSelected,
onSelect = onSelectDate,
)
}
}
}
}
}
}
}
}
// Centered start lets the pager scroll forward and backward freely while
// keeping page indices small enough for the underlying lazy list. 100k pages
// in either direction is ~1900 years — far beyond any reasonable navigation.
private const val PAGE_COUNT: Int = 200_000
private const val INITIAL_PAGE: Int = PAGE_COUNT / 2

View File

@@ -1,461 +0,0 @@
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.IndicationNodeFactory
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledIcon
import com.composeunstyled.UnstyledTab
import com.composeunstyled.UnstyledTabGroup
import com.composeunstyled.UnstyledTabList
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_close_a11y
import kotlin.math.roundToInt
/**
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
*
* Two structurally distinct shapes:
* - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs.
* Icon + label always shown (D-02); the sliding pill follows the active
* tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface]
* with `height / 2` corner radius.
* - **Collapsed** (`collapsed=true`): a single circular [CircleGlassButton]
* showing the active tab's icon. Tapping invokes [onCollapsedTap] (closes
* search per D-05).
*
* The two shapes are NOT animated between in-place — AppShell already
* cross-fades the expanded and collapsed instances via its own [AnimatedContent]
* when search opens / closes.
*
* ## Why the substrate is a *sibling* of the pill (not a parent)
*
* The pressed-tab affordance ([ExpandedDockTabs]) scales the pill up to 1.20×.
* For the pill to visibly extend *past* the dock's rounded contours, it cannot
* live inside the dock's [GlassSurface], whose `Modifier.clip(shape)` would
* crop it back to the rounded rect. So we wrap both in a no-clip [Box] and
* draw the pill as a sibling on top of the substrate — that's also why the
* substrate's `content` block is empty here.
*
* Substrate: only the shared [GlassSurface] / [CircleGlassButton] are used —
* direct Liquid API calls are forbidden here per CLAUDE.md non-negotiable #10.
*
* Touch targets: each tab cell + collapsed toggle is ≥ 44 dp (UI-SPEC line 52, 224).
*/
@Composable
fun DockBar(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
collapsed: Boolean,
onTabSelect: (BottomBarDestination) -> Unit,
onCollapsedTap: () -> Unit,
modifier: Modifier = Modifier,
height: Dp = 56.dp,
) {
if (collapsed) {
CircleGlassButton(
onClick = onCollapsedTap,
icon = active.icon,
contentDescription = stringResource(Res.string.search_close_a11y),
modifier = modifier,
size = height,
iconTint = RecipeTheme.colors.accent,
)
} else {
// Outer Box — no clip — hosts the dock substrate AND the tabs+pill
// layer so the pressed pill can scale (1.20×) past the dock contours.
Box(modifier = modifier.height(height)) {
// Substrate. Border is suppressed here so we can re-draw it on
// TOP of the pill at the end of the stack — that way the dock's
// outline stays visible through the (inner) pill GlassSurface,
// especially when the pill is pressed and scales past the dock.
GlassSurface(
modifier = Modifier.fillMaxSize(),
cornerRadius = height / 2,
border = null,
) {
// Empty: the actual pill + tabs live in the sibling overlay
// below, outside this GlassSurface's content clip.
}
ExpandedDockTabs(
destinations = destinations,
active = active,
dockHeight = height,
onTabSelect = onTabSelect,
)
// Top-z dock outline so the substrate's contour reads even where
// the pill overlaps it. Pure hairline (no fill) — purely a draw
// marker; doesn't intercept input.
Box(
modifier =
Modifier
.fillMaxSize()
.border(
BorderStroke(1.dp, RecipeTheme.colors.borderCard),
RoundedCornerShape(height / 2),
),
)
}
}
}
/**
* Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so
* we can drive a `Modifier.offset { IntOffset(...) }` without re-converting
* each frame.
*/
private data class TabBounds(
val offsetXPx: Float,
val widthPx: Float,
)
@Composable
private fun ExpandedDockTabs(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
dockHeight: Dp,
onTabSelect: (BottomBarDestination) -> Unit,
) {
val density = LocalDensity.current
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
// One [MutableInteractionSource] per tab so the pill can react to whichever
// tab the finger is *currently* down on — not just the active one.
val interactionSources =
remember(destinations) {
destinations.associateWith { MutableInteractionSource() }
}
// Subscribe to each tab's press state. `forEach` is inline, so the
// @Composable scope of this function propagates into the loop body and
// `collectIsPressedAsState` is a legal call here. `pressedTab` is a plain
// local recomputed per recomposition (cheap; only 4 tabs).
var pressedTab: BottomBarDestination? = null
destinations.forEach { dest ->
val pressed by interactionSources.getValue(dest).collectIsPressedAsState()
if (pressed) pressedTab = dest
}
// The pill follows whichever tab the finger is on; it settles back to
// the active tab once the press ends (with no click) OR onSelected has
// already updated `active` to match (with a click).
val pillTargetTab = pressedTab ?: active
// Pill is rendered wider than the cell so the indicator visually
// dominates without resizing any other cell. The pill bleeds into the
// 2 dp inter-cell gap and slightly into adjacent cells; icons + labels
// remain on top (z-order), readable above the dark substrate.
val pillExpansion = 8.dp
val pillExpansionPx = with(density) { pillExpansion.toPx() }
val pillX = remember { Animatable(0f) }
val pillW = remember { Animatable(0f) }
val pillScale = remember { Animatable(1f) }
var initialized by remember { mutableStateOf(false) }
// Drives the pill's tint: while either is true the pill stays translucent
// ("glass"); once both go false the pill settles to an opaque resting
// tint. `isPressActive` covers the user holding a finger down; the two
// `isXxxAnimating` flags cover the X/W slide and the scale-back-down so
// the pill stays glassy until the animations have fully settled.
var isXWAnimating by remember { mutableStateOf(false) }
var isScaleAnimating by remember { mutableStateOf(false) }
// First measurement: snap pill to the active cell so cold paint is correct.
LaunchedEffect(tabPositions[pillTargetTab]) {
if (initialized) return@LaunchedEffect
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
pillX.snapTo(t.offsetXPx - pillExpansionPx)
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
initialized = true
}
// Every subsequent change to the *target* tab — whether triggered by a tap
// (active changes) or by a press-down on an inactive tab (pressedTab
// changes) — animates the pill across in a single 200 ms tween. Cells are
// uniform-weight so the bounds captured here stay valid for the full
// animation; nothing moves under the pill mid-flight.
LaunchedEffect(pillTargetTab) {
if (!initialized) return@LaunchedEffect
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
isXWAnimating = true
try {
coroutineScope {
launch {
pillX.animateTo(
targetValue = t.offsetXPx - pillExpansionPx,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
)
}
launch {
pillW.animateTo(
targetValue = t.widthPx + 2f * pillExpansionPx,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
)
}
}
} finally {
isXWAnimating = false
}
}
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
// FastOutSlowInEasing so all chrome interactions read uniformly.
//
// - Scale 1.0 → 1.35: the pill "lifts" past the dock's outer rounded
// contours. The rest pill sits at a 4 dp vertical inset (visual height
// = dockHeight 8 dp). 1.35× grows it by ~10 dp on each side from its
// centre, which leaves ~6 dp sticking out above and below the dock —
// clearly past the substrate, not hugging the edge.
// - Same uniform factor on width preserves the rest pill's shape (a
// full capsule, cornerRadius = height/2 scales with the rest of the
// rect, so the scaled pill is *the same shape, just bigger*).
val isPressActive = pressedTab != null
LaunchedEffect(isPressActive) {
isScaleAnimating = true
try {
pillScale.animateTo(
targetValue = if (isPressActive) 1.35f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
)
} finally {
isScaleAnimating = false
}
}
// Pill is "busy" (and therefore stays glassy) while the user is holding
// it OR while it's still animating in any axis. Once everything settles,
// it crossfades to an opaque resting tint so the active tab reads as a
// clear solid pill rather than a translucent ghost.
val isPillBusy = isPressActive || isXWAnimating || isScaleAnimating
val pillBusyTint = Color.White.copy(alpha = 0.18f)
val pillRestingTint = Color(0xFF44474B)
val pillTint by animateColorAsState(
targetValue = if (isPillBusy) pillBusyTint else pillRestingTint,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill tint",
)
// Border only reads while the pill is glassy — when the pill settles to
// the opaque resting tint it becomes a solid plate and a hairline would
// just compete with the dock's outer outline. Animate the stroke's alpha
// so the border crossfades in/out together with the tint.
val pillBorderTarget = RecipeTheme.colors.borderCard
val pillBorderColor by animateColorAsState(
targetValue = if (isPillBusy) pillBorderTarget else pillBorderTarget.copy(alpha = 0f),
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill border",
)
// Liquid's `edge` rim is rendered even when the tint is fully opaque (the
// lens itself is nullified, but rim lighting still draws). Zero it out in
// the resting state — otherwise the pill keeps a visible bright outline
// even when we wanted a clean solid plate.
val pillEdge by animateFloatAsState(
targetValue = if (isPillBusy) 0.05f else 0f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill edge",
)
// Pill's resting visual height after the 4 dp inset on all sides.
val pillCorner = (dockHeight - 8.dp) / 2
Box(
modifier =
Modifier
.fillMaxSize()
// sm (8 dp) inner padding gives the pill room to expand up to
// 8 dp past its cell while still leaving the matching 4 dp gap
// to the dock's outer rounded edge on first / last tabs.
.padding(horizontal = RecipeTheme.spacing.sm),
) {
if (initialized) {
// The pill itself — a [GlassSurface] so the press-state can morph
// from "dark dim" to "glass" by tint alone, smoothly. Drawn FIRST
// so the tab list renders on top; .scale() at the end of the chain
// grows the pill (including its rounded clip) past the laid-out
// bounds with no parent clip to crop it.
GlassSurface(
modifier =
Modifier
.offset { IntOffset(pillX.value.roundToInt(), 0) }
.width(with(density) { pillW.value.toDp() })
.fillMaxHeight()
.padding(4.dp)
.scale(pillScale.value),
cornerRadius = pillCorner,
tint = pillTint,
border = BorderStroke(1.dp, pillBorderColor),
edgeIntensity = pillEdge,
) {}
}
// Tab row on top — icons + labels are drawn over the pill so the
// active tab's foreground (accent) reads against the dark inset, and
// the press-glass tint never obscures the pressed cell's icon.
//
// [NoIndication] override: `UnstyledTab`'s `indication` parameter is
// non-nullable (unlike `UnstyledButton`'s), so we can't pass `null` to
// suppress the platform state-layer / ripple. The pill IS our press
// indication; without this override the platform ripple draws inside
// the tab cell *under* the scaled glass pill, reading as a stray dark
// tint bleeding through.
CompositionLocalProvider(LocalIndication provides NoIndication) {
UnstyledTabGroup(
selectedTab = active.name,
tabs = destinations.map { it.name },
modifier = Modifier.fillMaxSize(),
) {
UnstyledTabList(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { dest ->
DockTabCell(
destination = dest,
isActive = dest == active,
interactionSource = interactionSources.getValue(dest),
onClick = { onTabSelect(dest) },
// Uniform weight — cells stay fixed during a tab
// switch. The "active feels bigger" emphasis is
// carried by the pill (size + tint), not by
// resizing the cell.
modifier =
Modifier
.weight(1f)
.onGloballyPositioned { coords ->
tabPositions[dest] =
TabBounds(
offsetXPx = coords.positionInParent().x,
widthPx = coords.size.width.toFloat(),
)
},
)
}
}
}
}
}
}
/**
* No-op [IndicationNodeFactory]. Provided in place of [LocalIndication.current]
* around the dock tab list so [UnstyledTab]'s `Modifier.selectable` doesn't
* paint a platform state-layer / ripple inside the cell — that would draw
* *under* the scaled-up glass pill and read as a stray tint bleeding through.
*
* The pill (size + glass tint) IS the press affordance; nothing else needed.
*/
private object NoIndication : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode = object : Modifier.Node() {}
override fun hashCode(): Int = 0
override fun equals(other: Any?): Boolean = other === this
}
@Composable
private fun DockTabCell(
destination: BottomBarDestination,
isActive: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Both states are fully opaque (alpha 1.0) — chrome foreground must not
// visually compete with the glass tafla underneath. `contentMuted` reads
// as transparent over translucent glass, so we use `content` for inactive
// tabs and rely on `accent` (saturated) to call out the active one.
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.content
val labelText = stringResource(destination.labelRes)
val a11ySuffix = if (isActive) ", aktywna" else ""
UnstyledTab(
key = destination.name,
selected = isActive,
onSelected = onClick,
activateOnFocus = false,
interactionSource = interactionSource,
shape = RoundedCornerShape(50),
backgroundColor = Color.Transparent,
contentPadding = PaddingValues(0.dp),
modifier =
modifier
.fillMaxSize()
.semantics {
contentDescription = labelText + a11ySuffix
},
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
UnstyledIcon(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.size(2.dp))
BasicText(
text = labelText,
style = RecipeTheme.typography.label.copy(color = tint),
)
}
}
}
}

View File

@@ -24,22 +24,6 @@ import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Circular Liquid-glass icon button with iOS-style press feedback.
*
* Visual behaviour on press:
* - Scale 1.0 → 1.15 (whole button briefly grows under the finger).
* - Substrate tint shifts from [RecipeTheme.colors.surfaceGlass] to a
* translucent white overlay, so the button reads "lit up".
*
* Both transitions use the same 120 ms tween with [FastOutSlowInEasing], so
* the scale and tint move together. Compose's default [androidx.compose.foundation.Indication]
* (ripple / state-layer) is suppressed (`indication = null`) — this scale +
* tint pair is the project's standard press affordance for circular chrome.
*
* Used by the dock's floating search button, the search overlay's dismiss
* button, and any future round glass action in the chrome family.
*/
@Composable
fun CircleGlassButton(
onClick: () -> Unit,

View File

@@ -3,48 +3,40 @@ package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.rememberLiquidState
val LocalGlassBackdropState =
staticCompositionLocalOf<GlassBackdropState> {
error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
}
/**
* Shared source/sampling state for glass chrome.
*
* AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
* consume [LocalGlassBackdropState] so Liquid sample the same layer behind
* the dock/search chrome.
*/
@Stable
class GlassBackdropState internal constructor(
internal val liquidState: Any,
internal val liquidState: LiquidState,
)
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
@Composable
fun rememberGlassBackdropState(): GlassBackdropState {
val liquidState = rememberLiquidBackdropHandle()
val liquidState = rememberLiquidState()
return remember(liquidState) {
GlassBackdropState(
liquidState = liquidState,
)
GlassBackdropState(liquidState)
}
}
@Composable
fun GlassBackdropSource(
state: GlassBackdropState,
modifier: Modifier = Modifier,
state: GlassBackdropState = rememberGlassBackdropState(),
content: @Composable BoxScope.() -> Unit,
) {
CompositionLocalProvider(LocalGlassBackdropState provides state) {
Box(
modifier =
modifier
.liquidBackdropSource(state),
content = content,
)
}
Box(
modifier = modifier.liquefiable(state.liquidState),
content = content,
)
}

View File

@@ -1,23 +1,55 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.liquid
/**
* @param recordAsSource Also register this surface as a Liquid source so other
* [GlassSurface]s sampling the same backdrop see this surface's refracted
* output — needed for nested glass-on-glass (e.g. a press overlay over the
* dock substrate). Liquid's ancestor-exclusion prevents this surface from
* sampling itself; outside its bounds it contributes nothing, so siblings
* that extend past the source's edges fall back to the shell backdrop
* seamlessly.
*/
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
edgeIntensity: Float = 0.05f,
glassStyle: RecipeGlassStyle = RecipeTheme.glass.menu,
recordAsSource: Boolean = false,
content: @Composable BoxScope.() -> Unit,
) {
val backdropState = LocalGlassBackdropState.current
LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, edgeIntensity, content)
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.then(if (recordAsSource) Modifier.liquefiable(backdropState.liquidState) else Modifier)
.liquid(backdropState.liquidState) {
refraction = glassStyle.refraction
curve = glassStyle.curve
edge = glassStyle.edge
dispersion = glassStyle.dispersion
saturation = glassStyle.saturation
contrast = glassStyle.contrast
frost = glassStyle.frost
this.shape = shape
this.tint = tint
},
content = content,
)
}

View File

@@ -32,26 +32,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Pill-shaped Liquid-glass text input with iOS-style press feedback.
*
* Visual behaviour on press:
* - Scale 1.0 → 1.05 (subtle — the pill is wide, so even small percentages
* are readable). Tween 120 ms `FastOutSlowInEasing`, matching the project's
* standard chrome-interaction timing.
* - **No** tint change — the keyboard appearing is its own colour event, so
* additional brightness on the field would compete.
*
* Press detection uses `Modifier.pointerInput` with `awaitEachGesture`, but
* never *consumes* the down event — the wrapped [BasicTextField] still
* receives the tap and handles focus / IME naturally. The scale animation
* runs concurrently with the focus request, so the user sees the pill bounce
* up the moment they touch it, while the keyboard slides into place.
*
* Reusable for any glass-style text input. [leadingContent] is a `null`-able
* slot for a leading icon or other affordance; if null, the field starts at
* the pill's leading edge.
*/
@Composable
fun GlassTextField(
value: String,

View File

@@ -1,59 +0,0 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.liquid
import io.github.fletchmckee.liquid.rememberLiquidState
/**
* Liquid backend per CONTEXT D-16. The source layer is applied by
* [GlassBackdropSource] through [liquidBackdropSource], and chrome consumes the
* same [LiquidState] here.
*/
@Composable
internal fun LiquidGlassSurface(
modifier: Modifier,
tint: Color,
cornerRadius: Dp,
border: BorderStroke?,
backdropState: GlassBackdropState?,
edgeIntensity: Float,
content: @Composable BoxScope.() -> Unit,
) {
val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState()
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.liquid(state) {
refraction = 0.10f
curve = 0.5f
edge = edgeIntensity
dispersion = 0.05f
saturation = 0.5f
contrast = 1.5f
frost = 10.dp
this.shape = shape
this.tint = tint
}
.let { if (border != null) it.border(border, shape) else it },
content = content,
)
}
@Composable
internal fun rememberLiquidBackdropHandle(): Any = rememberLiquidState()
internal fun Modifier.liquidBackdropSource(state: GlassBackdropState): Modifier = liquefiable(state.liquidState as LiquidState)

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.screens.recipes
package dev.ulfrx.recipe.ui.screens.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -14,24 +14,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_recipes_subtitle
import recipe.composeapp.generated.resources.empty_recipes_title
import recipe.composeapp.generated.resources.shell_tab_recipes
import recipe.composeapp.generated.resources.empty_home_subtitle
import recipe.composeapp.generated.resources.empty_home_title
import recipe.composeapp.generated.resources.shell_tab_home
/**
* Phase 2.1 empty-state screen for the Recipes tab. Phase 5 replaces the
* empty body with the recipe catalog grid.
*
* Search is now shell-wide (see `AppShell` + `ShellSearchViewModel`) this
* screen no longer owns any bottom-chrome state.
*/
@Composable
fun RecipesScreen(viewModel: RecipesViewModel) {
fun HomeScreen(viewModel: HomeViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -47,15 +40,15 @@ fun RecipesScreen(viewModel: RecipesViewModel) {
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_recipes),
text = stringResource(Res.string.shell_tab_home),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Recipes.icon,
title = stringResource(Res.string.empty_recipes_title),
subtitle = stringResource(Res.string.empty_recipes_subtitle),
icon = DockDestination.Home.icon,
title = stringResource(Res.string.empty_home_title),
subtitle = stringResource(Res.string.empty_home_subtitle),
)
}
}

View File

@@ -0,0 +1,15 @@
package dev.ulfrx.recipe.ui.screens.home
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class HomeState(
val isEmpty: Boolean = true,
)
class HomeViewModel : ViewModel() {
private val _state = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state.asStateFlow()
}

View File

@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
@@ -52,7 +52,7 @@ fun PantryScreen(viewModel: PantryViewModel) {
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Pantry.icon,
icon = DockDestination.Pantry.icon,
title = stringResource(Res.string.empty_pantry_title),
subtitle = stringResource(Res.string.empty_pantry_subtitle),
)

View File

@@ -1,197 +1,81 @@
package dev.ulfrx.recipe.ui.screens.planner
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.ui.components.calendar.SwipeableCalendar
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.shell_tab_planner
/**
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
* empty body with the calendar grid.
* Phase 2.1 — planner shell with the shared calendar at the top. Phase 6 fills
* in the area below the calendar with meal slots driven by [PlannerState.selectedDate].
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable
fun PlannerScreen(viewModel: PlannerViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
val bgDark = Color(0xFF14181F)
val titleColor = Color(0xFFE8E4DC)
val today = remember { todayInSystemTz() }
Box(
modifier =
Modifier
.fillMaxSize()
.background(bgDark),
.background(RecipeTheme.colors.background),
) {
// Scrollable, visually rich content sitting behind the glass chrome.
// Bottom contentPadding extends well past the dock so items keep
// scrolling under it (the whole point of this test view).
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
contentPadding =
PaddingValues(
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
top = RecipeTheme.spacing.xl + 48.dp,
bottom = 160.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = GlassTestItems, key = { it.id }) { item ->
GlassTestCard(item = item)
}
}
// Title pinned at the top so the chrome glass doesn't have to refract
// over the very top of the scrollable list.
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style = RecipeTheme.typography.title.copy(color = titleColor),
modifier =
Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(
top = RecipeTheme.spacing.xl,
start = RecipeTheme.spacing.lg,
),
)
}
}
private data class GlassTestItem(
val id: Int,
val accent: Color,
val cardTone: Color,
val titleWeight: Float,
val subtitleWeight: Float,
)
private val GlassTestItems: List<GlassTestItem> =
run {
val accents =
listOf(
Color(0xFFD97757), // accent terracotta
Color(0xFF6EA987), // sage
Color(0xFF7A8FB8), // dusty blue
Color(0xFFC1864F), // amber
Color(0xFFB76E79), // muted rose
Color(0xFF6B7A8F), // slate
Color(0xFF8E7CC3), // muted violet
)
val tones =
listOf(
Color(0xFF1F242C),
Color(0xFF232932),
Color(0xFF1B2028),
Color(0xFF272D36),
)
List(40) { i ->
GlassTestItem(
id = i,
accent = accents[i % accents.size],
cardTone = tones[i % tones.size],
titleWeight = 0.80f + ((i * 13) % 20) / 100f,
subtitleWeight = 0.55f + ((i * 7) % 40) / 100f,
)
}
}
@Composable
private fun GlassTestCard(item: GlassTestItem) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(88.dp)
.clip(RoundedCornerShape(16.dp))
.background(item.cardTone),
) {
// Left accent stripe — varied saturated colors so the dock chrome
// gets to refract a clear hue band as you scroll past.
Box(
modifier =
Modifier
.width(6.dp)
.fillMaxSize()
.background(item.accent),
)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
start = 12.dp + 6.dp,
end = 12.dp,
top = 12.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
.windowInsetsPadding(WindowInsets.statusBars),
) {
// Title bar
Box(
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style =
RecipeTheme.typography.title.copy(
color = RecipeTheme.colors.content,
),
modifier =
Modifier
.fillMaxWidth(item.titleWeight)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
Modifier.padding(
top = RecipeTheme.spacing.xl,
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
),
)
// Subtitle bar
Box(
modifier =
Modifier
.fillMaxWidth(item.subtitleWeight)
.height(10.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
)
Spacer(modifier = Modifier.height(2.dp))
// Faint metadata dot + bar
Box(
modifier =
Modifier
.fillMaxWidth(0.18f)
.height(8.dp)
.clip(RoundedCornerShape(2.dp))
.background(item.accent.copy(alpha = 0.55f)),
Spacer(modifier = Modifier.height(RecipeTheme.spacing.lg))
SwipeableCalendar(
selectedDate = state.selectedDate,
today = today,
mode = state.calendarMode,
onSelectDate = viewModel::selectDate,
onModeChange = viewModel::setCalendarMode,
// Swipe auto-follows: dropping into a new week/month bumps
// the selection by the same offset (kotlinx.datetime clamps
// day-of-month for short months).
onVisibleAnchorChange = viewModel::selectDate,
expandable = true,
modifier = Modifier.fillMaxWidth(),
)
}
Box(
modifier =
Modifier
.size(20.dp)
.padding(end = 0.dp),
)
}
}

View File

@@ -1,19 +1,39 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.calendar.CalendarMode
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
/**
* UI state for [PlannerScreen]. Phase 2.1 ships only the empty state. Phase 6
* (Meal Planner — Core Write Path) extends this with calendar data + actions.
* UI state for [PlannerScreen]. Phase 2.1 ships only the calendar; Phase 6
* extends this with day-plan data, meal slot actions, and pantry/shortfall
* derivations driven by [selectedDate].
*/
data class PlannerState(
val isEmpty: Boolean = true,
val selectedDate: LocalDate,
val calendarMode: CalendarMode,
)
class PlannerViewModel : ViewModel() {
private val _state = MutableStateFlow(PlannerState())
private val _state =
MutableStateFlow(
PlannerState(
selectedDate = todayInSystemTz(),
calendarMode = CalendarMode.Week,
),
)
val state: StateFlow<PlannerState> = _state.asStateFlow()
fun selectDate(date: LocalDate) {
_state.update { it.copy(selectedDate = date) }
}
fun setCalendarMode(mode: CalendarMode) {
_state.update { it.copy(calendarMode = mode) }
}
}

View File

@@ -1,19 +0,0 @@
package dev.ulfrx.recipe.ui.screens.recipes
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [RecipesScreen]. Phase 2.1 ships only the empty state. Phase 5
* (Recipe Catalog Read Path) extends this with `recipes` etc.
*/
data class RecipesState(
val isEmpty: Boolean = true,
)
class RecipesViewModel : ViewModel() {
private val _state = MutableStateFlow(RecipesState())
val state: StateFlow<RecipesState> = _state.asStateFlow()
}

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe.ui.screens.search
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.search.SearchState
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

View File

@@ -1,95 +1,40 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.RootNavDisplay
import dev.ulfrx.recipe.navigation.TabNavigator
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_placeholder
/**
* Authenticated root composable. Owns:
* - the per-tab navigation back stacks via [TabNavigator]
* - the shell-wide search affordance via [ShellSearchViewModel]
*
* ## Body modes (driven by `searchVm.state.isOpen`)
*
* - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom
* chrome is `[DockBar (full)] [FloatingSearchButton]`.
* - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom
* chrome is [SearchPillRow], whose layout shifts further on `isFocused`
* (collapsed dock icon yields to a full-width pill + X — see SearchChrome.kt).
*
* ## Back-press handling
*
* While search is open, a [BackHandler] consumes the back press as a no-op:
* the user must exit search explicitly via the collapsed dock icon (B→A) or X
* (C→B). Confirmed product decision — no implicit dismissal while in search.
*
* ## Why TabNavigator and not the AndroidX NavController
* (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See
* [RootNavDisplay] for the full rationale.)
*/
@OptIn(ExperimentalComposeUiApi::class)
@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the
// latter is overkill for a static "consume back" guard. Revisit when stable.
@Preview
@Composable
fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() }
val searchVm: ShellSearchViewModel = koinViewModel()
val searchState by searchVm.state.collectAsStateWithLifecycle()
// Hoisted so both the body (liquefiable source) and the bottom chrome
// (liquid samplers) share a single LiquidState. Without this the chrome
// would fall back to a fresh, sourceless state and render as flat tint.
val backdropState = rememberGlassBackdropState()
BackHandler(enabled = searchState.isOpen) {
// Blocked — user must exit search via explicit affordance (dock icon or X).
}
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
Box(
modifier =
@@ -97,7 +42,6 @@ fun AppShell(modifier: Modifier = Modifier) {
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
// Body — cross-fade between the tab stack and the search overlay.
GlassBackdropSource(
state = backdropState,
modifier = Modifier.fillMaxSize(),
@@ -122,115 +66,20 @@ fun AppShell(modifier: Modifier = Modifier) {
}
}
// Bottom chrome — Apple-Music-style: don't respect the full nav-bar
// inset (home indicator) for the bottom edge; halve it so chrome sits
// close to the bottom and the home indicator visually overlaps the
// chrome substrate. When IME is up, use the full IME inset (it's much
// larger than navInset/2, so `max` keeps the chrome above the keyboard).
val bottomInset =
with(LocalDensity.current) {
val imePx = WindowInsets.ime.getBottom(this)
val navPx = WindowInsets.navigationBars.getBottom(this)
maxOf(imePx, navPx / 2).toDp()
}
// Horizontal chrome padding animates with the search state:
// - Closed (dock visible) → xl (24 dp)
// - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly
// inset from the dock's footprint
// - Open, focused (search C) → 8 dp, so the input reads as a width
// extension of the keyboard above it
val horizontalPadding by animateDpAsState(
targetValue =
when {
!searchState.isOpen -> RecipeTheme.spacing.xl
!searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp
else -> 8.dp
},
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
label = "chrome horizontal padding",
ShellBottomChrome(
activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab,
search =
SearchHandlers(
state = searchState,
onOpen = searchVm::open,
onQueryChange = searchVm::onQueryChange,
onClose = searchVm::close,
onFocus = searchVm::focus,
onUnfocus = searchVm::unfocus,
),
modifier = Modifier.align(Alignment.BottomCenter),
)
Row(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(
start = horizontalPadding,
end = horizontalPadding,
top = RecipeTheme.spacing.sm,
bottom = bottomInset + RecipeTheme.spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(
targetState = searchState.isOpen,
// Lock chrome region to the dock's height in both modes so
// (a) the body above doesn't shift when search opens / closes,
// and (b) the (shorter) search pill is centred vertically
// inside the same band the dock occupies.
modifier = Modifier.fillMaxWidth().height(63.dp),
contentAlignment = Alignment.Center,
// Exit is instant (no fade-out): the outgoing chrome cell —
// dock OR search pill row — may still be playing its press
// animation (the user's finger triggered the tap that switched
// states). If we also fade it out, the half-faded pressed-up
// button overlaps visually with the incoming pill, which reads
// as "two things on screen at once". Instant exit makes the
// hand-off feel clean while the press animation keeps running
// off-screen on the now-removed branch.
transitionSpec = {
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
ExitTransition.None
},
label = "AppShell bottom chrome",
) { searchOpen ->
if (searchOpen) {
SearchPillRow(
query = searchState.query,
isFocused = searchState.isFocused,
placeholder = stringResource(Res.string.search_placeholder),
activeTab = navigator.activeTab,
onQueryChange = searchVm::onQueryChange,
onClose = searchVm::close,
onFocusGained = searchVm::focus,
onFocusLost = searchVm::unfocus,
)
} else {
DefaultDockRow(
activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab,
onSearchTap = searchVm::open,
)
}
}
}
}
}
}
@Composable
private fun DefaultDockRow(
activeTab: BottomBarDestination,
onTabSelect: (BottomBarDestination) -> Unit,
onSearchTap: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = false,
onTabSelect = onTabSelect,
onCollapsedTap = { /* unreachable in default mode */ },
modifier = Modifier.weight(1f),
height = 63.dp,
)
Box(modifier = Modifier.size(63.dp)) {
FloatingSearchButton(onClick = onSearchTap)
}
}
}

View File

@@ -0,0 +1,174 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.screens.shell.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.screens.shell.search.SearchPillRow
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
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_placeholder
/**
* Search-side inputs for [ShellBottomChrome] — the live [SearchState] plus the
* lambdas the chrome calls back into. Bundled into one holder so the chrome's
* parameter list doesn't grow with the VM, and so a `@Preview` can construct
* one with no-op lambdas to render any of the three states without a real VM.
*
* Data class on purpose: structural equality means Compose can skip-recompose
* the chrome when [AppShell] re-emits an identical handler bag (lambdas built
* from the same VM method references compare equal).
*/
data class SearchHandlers(
val state: SearchState,
val onOpen: () -> Unit,
val onQueryChange: (String) -> Unit,
val onClose: () -> Unit,
val onFocus: () -> Unit,
val onUnfocus: () -> Unit,
)
/**
* Bottom chrome for [AppShell]. Owns the dock ↔ search-pill swap and the
* three-state geometry choreography (insets, horizontal-padding curve, height
* lock, AnimatedContent transition tuning).
*
* Modes — driven by [search].state:
* - **A (closed)** — `[DockBar (full)] [FloatingSearchButton]`
* - **B (open, unfocused)** — `[collapsed dock icon] [search pill]`
* - **C (open, focused)** — `[search pill (full width)] [X button]`
*
* Geometry contract (kept here so [AppShell] doesn't need to know any of it):
* - The chrome band is height-locked to the dock's 63 dp so the body above
* doesn't shift when search opens/closes; the (shorter) search pill is
* centred vertically inside that band.
* - Horizontal padding animates with state (xl → xl+2 → 8 dp). The narrow C
* inset makes the focused input read as a width extension of the keyboard
* above it.
* - Bottom inset uses navBar/2 (Apple Music pattern — chrome sits close to
* the bottom and the home indicator visually overlaps the substrate). When
* the IME is up the IME inset wins via `max`.
*/
@Composable
fun ShellBottomChrome(
activeTab: DockDestination,
onTabSelect: (DockDestination) -> Unit,
search: SearchHandlers,
modifier: Modifier = Modifier,
) {
val bottomInset =
with(LocalDensity.current) {
val imePx = WindowInsets.ime.getBottom(this)
val navPx = WindowInsets.navigationBars.getBottom(this)
maxOf(imePx, navPx / 2).toDp()
}
val horizontalPadding by animateDpAsState(
targetValue =
when {
!search.state.isOpen -> RecipeTheme.spacing.xl
!search.state.isFocused -> RecipeTheme.spacing.xl + 2.dp
else -> 8.dp
},
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
label = "chrome horizontal padding",
)
Row(
modifier =
modifier
.fillMaxWidth()
.padding(
start = horizontalPadding,
end = horizontalPadding,
top = RecipeTheme.spacing.sm,
bottom = bottomInset + RecipeTheme.spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(
targetState = search.state.isOpen,
modifier = Modifier.fillMaxWidth().height(63.dp),
contentAlignment = Alignment.Center,
// Exit is instant (no fade-out): the outgoing chrome cell — dock
// OR search pill row — may still be playing its press animation
// (the user's finger triggered the tap that switched states). If
// we also fade it out, the half-faded pressed-up button overlaps
// visually with the incoming pill, which reads as "two things on
// screen at once". Instant exit keeps the hand-off clean while
// the press animation finishes off-screen on the removed branch.
transitionSpec = {
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
ExitTransition.None
},
label = "AppShell bottom chrome",
) { searchOpen ->
if (searchOpen) {
SearchPillRow(
query = search.state.query,
isFocused = search.state.isFocused,
placeholder = stringResource(Res.string.search_placeholder),
activeTab = activeTab,
onQueryChange = search.onQueryChange,
onClose = search.onClose,
onFocusGained = search.onFocus,
onFocusLost = search.onUnfocus,
)
} else {
DockRow(
activeTab = activeTab,
onTabSelect = onTabSelect,
onSearchTap = search.onOpen,
)
}
}
}
}
@Composable
private fun DockRow(
activeTab: DockDestination,
onTabSelect: (DockDestination) -> Unit,
onSearchTap: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
DockBar(
destinations = DockDestination.entries,
active = activeTab,
collapsed = false,
onTabSelect = onTabSelect,
modifier = Modifier.weight(1f),
height = 63.dp,
)
Box(modifier = Modifier.size(63.dp)) {
FloatingSearchButton(onClick = onSearchTap)
}
}
}

View File

@@ -0,0 +1,138 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.dock_expand_a11y
@Composable
fun DockBar(
destinations: List<DockDestination>,
active: DockDestination,
collapsed: Boolean,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier = Modifier,
height: Dp = 56.dp,
) {
if (collapsed) {
DockBarCollapsed(
active = active,
onTabSelect = onTabSelect,
modifier = modifier,
height = height,
)
} else {
DockBarExpanded(
destinations = destinations,
active = active,
onTabSelect = onTabSelect,
modifier = modifier,
height = height,
)
}
}
@Composable
private fun DockBarCollapsed(
active: DockDestination,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier,
height: Dp,
) {
CircleGlassButton(
onClick = { onTabSelect(active) },
icon = active.icon,
contentDescription = stringResource(Res.string.dock_expand_a11y),
modifier = modifier,
size = height,
iconTint = RecipeTheme.colors.accent,
)
}
@Composable
private fun DockBarExpanded(
destinations: List<DockDestination>,
active: DockDestination,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier,
height: Dp,
) {
val tabBounds = remember { mutableStateMapOf<Int, TabBounds>() }
var pressState by remember { mutableStateOf<DockPressState>(DockPressState.Idle) }
var dockWidthPx by remember { mutableStateOf(0f) }
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
Box(
modifier =
modifier
.height(height)
.onSizeChanged { dockWidthPx = it.width.toFloat() }
.pointerInput(destinations) {
trackDockGesture { event ->
when (event) {
is DockPressEvent.Pressing -> {
pressState = DockPressState.Pressing(event.xPx)
}
is DockPressEvent.Released -> {
tabIndexAt(event.xPx, tabBounds)?.let { idx ->
onTabSelect(destinations[idx])
}
pressState = DockPressState.Idle
}
DockPressEvent.Cancelled -> {
pressState = DockPressState.Idle
}
}
}
},
) {
val anim = rememberDockOverlayAnimations(
pressState = pressState,
activeIndex = activeIndex,
tabBounds = tabBounds,
dockWidthPx = dockWidthPx,
density = LocalDensity.current,
)
DockSubstrate(cornerRadius = height / 2)
DockActiveIndicatorLayer(
activeIndex = activeIndex,
tabBounds = tabBounds,
dockWidthPx = dockWidthPx,
alpha = anim.activeIndicatorAlpha,
)
DockPressOverlayLayer(
overlayCenterX = anim.overlayCenterX,
overlayWidthPx = anim.overlayWidthPx,
overlayAlpha = anim.overlayAlpha,
overlayPeakProgress = anim.overlayPeakProgress,
dockWidthPx = dockWidthPx,
dockHeight = height,
)
DockTabRow(
destinations = destinations,
activeIndex = activeIndex,
tabBounds = tabBounds,
dockWidthPx = dockWidthPx,
onTabSelectFromA11y = onTabSelect,
onTabBoundsChange = { index, bounds -> tabBounds[index] = bounds },
)
}
}

View File

@@ -0,0 +1,69 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
private val ActiveIndicatorBleed = 4.dp
private val ActiveIndicatorEdgeInset = 5.dp
internal data class TabBounds(
val offsetXPx: Float,
val widthPx: Float,
)
internal sealed interface DockPressState {
data object Idle : DockPressState
data class Pressing(
val xPx: Float,
) : DockPressState
}
internal sealed interface DockPressEvent {
data class Pressing(
val xPx: Float,
) : DockPressEvent
data class Released(
val xPx: Float,
) : DockPressEvent
data object Cancelled : DockPressEvent
}
internal data class ActiveIndicatorBbox(
val leftPx: Float,
val rightPx: Float,
) {
val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
val centerPx: Float get() = (leftPx + rightPx) / 2f
}
internal fun activeIndicatorBboxFor(
cell: TabBounds,
dockWidthPx: Float,
density: Density,
): ActiveIndicatorBbox {
val bleedPx = with(density) { ActiveIndicatorBleed.toPx() }
val edgeInsetPx = with(density) { ActiveIndicatorEdgeInset.toPx() }
val left = (cell.offsetXPx - bleedPx).coerceAtLeast(edgeInsetPx)
val right = (cell.offsetXPx + cell.widthPx + bleedPx).coerceAtMost(dockWidthPx - edgeInsetPx)
return ActiveIndicatorBbox(left, right)
}
internal fun tabIndexAt(
x: Float,
bounds: Map<Int, TabBounds>,
): Int? {
if (bounds.isEmpty()) return null
val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
var result = sorted.first().key
for (entry in sorted) {
if (entry.value.offsetXPx <= x) {
result = entry.key
} else {
break
}
}
return result
}

View File

@@ -0,0 +1,186 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Cancelled
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Released
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
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
internal data class DockOverlayAnimations(
val overlayCenterX: Float,
val overlayWidthPx: Float,
val overlayAlpha: Float,
val overlayPeakProgress: Float,
val activeIndicatorAlpha: Float,
)
@Composable
internal fun rememberDockOverlayAnimations(
pressState: DockPressState,
activeIndex: Int,
tabBounds: Map<Int, TabBounds>,
dockWidthPx: Float,
density: Density,
): DockOverlayAnimations {
val activeBounds = tabBounds[activeIndex]
val activeCenterX = activeBounds?.let { it.offsetXPx + it.widthPx / 2f } ?: 0f
val bleedPx = with(density) { PressOverlayBleed.toPx() }
val overlayWidthPx = (activeBounds?.widthPx ?: 0f) + 2 * bleedPx
val centerXMin = overlayWidthPx / 2f
val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin)
val pressingXPx = (pressState as? DockPressState.Pressing)?.xPx
val clampedPressX = pressingXPx?.coerceIn(centerXMin, centerXMax)
val centerAnim = remember { Animatable(activeCenterX) }
val overlayAlphaAnim = remember { Animatable(0f) }
val activeAlphaAnim = remember { Animatable(1f) }
var wasPressed by remember { mutableStateOf(false) }
LaunchedEffect(clampedPressX, activeCenterX) {
when {
clampedPressX == null -> {
wasPressed = false
centerAnim.animateTo(
activeCenterX,
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SlideSettleStiffness,
visibilityThreshold = SettleEpsilonPx,
),
)
}
!wasPressed -> {
wasPressed = true
centerAnim.animateTo(
clampedPressX,
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SlideOutwardStiffness,
visibilityThreshold = SettleEpsilonPx,
),
)
}
else -> centerAnim.snapTo(clampedPressX)
}
}
val pressing = pressState is DockPressState.Pressing
val activeCenterXState = rememberUpdatedState(activeCenterX)
var releaseSlideStartX by remember { mutableStateOf<Float?>(null) }
LaunchedEffect(pressing) {
if (pressing) {
releaseSlideStartX = null
activeAlphaAnim.snapTo(0f)
overlayAlphaAnim.animateTo(
targetValue = 1f,
animationSpec = tween(OverlayFadeInDurationMs, 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)
if (tailMs > 0) {
overlayAlphaAnim.animateTo(1f, tween(tailMs, easing = FastOutSlowInEasing))
}
}
snapshotFlow {
!centerAnim.isRunning &&
abs(centerAnim.value - activeCenterXState.value) < SettleEpsilonPx
}.first { it }
coroutineScope {
launch {
overlayAlphaAnim.animateTo(
targetValue = 0f,
animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing),
)
}
launch {
activeAlphaAnim.animateTo(
targetValue = 1f,
animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing),
)
}
}
releaseSlideStartX = null
}
}
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(
overlayCenterX = centerAnim.value,
overlayWidthPx = overlayWidthPx,
overlayAlpha = overlayAlphaAnim.value,
overlayPeakProgress = overlayPeakProgress,
activeIndicatorAlpha = activeAlphaAnim.value,
)
}
internal suspend fun PointerInputScope.trackDockGesture(onPressEvent: (DockPressEvent) -> Unit) {
awaitEachGesture {
val pressDown = awaitFirstDown(requireUnconsumed = false)
pressDown.consume()
val pointerId = pressDown.id
onPressEvent(Pressing(pressDown.position.x))
while (true) {
val pointerEvent = awaitPointerEvent()
val pressChange = pointerEvent.changes.firstOrNull { it.id == pointerId }
if (pressChange == null) {
onPressEvent(Cancelled)
break
}
if (!pressChange.pressed) {
onPressEvent(Released(pressChange.position.x))
pressChange.consume()
break
}
if (pressChange.positionChanged()) {
onPressEvent(Pressing(pressChange.position.x))
}
pressChange.consume()
}
}
}

View File

@@ -0,0 +1,100 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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 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
@Composable
internal fun DockSubstrate(cornerRadius: Dp) {
GlassSurface(
modifier = Modifier.fillMaxSize(),
cornerRadius = cornerRadius,
recordAsSource = true,
) {}
}
@Composable
internal fun DockActiveIndicatorLayer(
activeIndex: Int,
tabBounds: Map<Int, TabBounds>,
dockWidthPx: Float,
alpha: Float,
) {
val bounds = tabBounds[activeIndex] ?: return
if (alpha <= 0f || dockWidthPx <= 0f) return
val density = LocalDensity.current
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),
),
)
}
@Composable
internal fun DockPressOverlayLayer(
overlayCenterX: Float,
overlayWidthPx: Float,
overlayAlpha: Float,
overlayPeakProgress: Float,
dockWidthPx: Float,
dockHeight: Dp,
) {
if (overlayAlpha <= 0f || dockWidthPx <= 0f || overlayWidthPx <= 0f) return
val density = LocalDensity.current
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 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),
cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress,
tint = RecipeTheme.colors.surfaceGlassOverlay,
) {}
}

View File

@@ -0,0 +1,130 @@
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
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.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
private val DockTabIconSize = 18.dp
private val DockTabIconLabelGap = 2.dp
private const val DockTabLabelFontSizeSp = 11
private const val DockTabLabelLineHeightSp = 13
@Composable
internal fun DockTabRow(
destinations: List<DockDestination>,
activeIndex: Int,
tabBounds: Map<Int, TabBounds>,
dockWidthPx: Float,
onTabSelectFromA11y: (DockDestination) -> Unit,
onTabBoundsChange: (Int, TabBounds) -> Unit,
) {
val density = LocalDensity.current
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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
}
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(),
),
)
},
)
}
}
}
@Composable
private fun DockTabItem(
destination: DockDestination,
isActive: Boolean,
contentOffsetPx: Float,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val label = stringResource(destination.labelRes)
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
}
},
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.offset { IntOffset(contentOffsetPx.roundToInt(), 0) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
UnstyledIcon(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(DockTabIconSize),
)
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
BasicText(
text = label,
style = RecipeTheme.typography.label.copy(
color = tint,
fontSize = DockTabLabelFontSizeSp.sp,
lineHeight = DockTabLabelLineHeightSp.sp,
),
)
}
}
}

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
@@ -20,9 +20,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -53,7 +53,7 @@ fun SearchPillRow(
query: String,
isFocused: Boolean,
placeholder: String,
activeTab: BottomBarDestination,
activeTab: DockDestination,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
onFocusGained: () -> Unit,
@@ -98,11 +98,12 @@ fun SearchPillRow(
exit = sideButtonExit,
) {
DockBar(
destinations = BottomBarDestination.entries,
destinations = DockDestination.entries,
active = activeTab,
collapsed = true,
onTabSelect = { /* unreachable while collapsed */ },
onCollapsedTap = onClose,
// Collapsed dock only emits a re-select of the active tab,
// which here means "close the search overlay".
onTabSelect = { onClose() },
height = pillHeight,
)
}

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
/**
* Shell-wide search state shape, exposed by

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable

View File

@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
@@ -52,7 +52,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) {
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Shopping.icon,
icon = DockDestination.Shopping.icon,
title = stringResource(Res.string.empty_shopping_title),
subtitle = stringResource(Res.string.empty_shopping_subtitle),
)

View File

@@ -10,9 +10,11 @@ public data class RecipeColors(
val background: Color,
val surface: Color,
val surfaceGlass: Color,
val surfaceGlassOverlay: Color,
val content: Color,
val contentMuted: Color,
val accent: Color,
val chromeActive: Color,
val separator: Color,
val borderCard: Color,
val destructive: Color,
@@ -20,12 +22,14 @@ public data class RecipeColors(
public val LightRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFFF7F5F1),
background = Color(0xFFEAE6DF),
surface = Color(0xFFFFFFFF),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f),
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.20f),
content = Color(0xFF0F1113),
contentMuted = Color(0xFF6B6E73),
accent = Color(0xFFD97757),
chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f),
separator = Color(0xFFE5E1DA),
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
destructive = Color(0xFFC0392B),
@@ -35,10 +39,12 @@ public val DarkRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFF0F1113),
surface = Color(0xFF1A1D21),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.18f),
surfaceGlass = Color(0xFF3A3D42).copy(alpha = 0.55f),
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.12f),
content = Color(0xFFF1EFEA),
contentMuted = Color(0xFF9AA0A6),
accent = Color(0xFFE48A6E),
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
separator = Color(0xFF2A2D31),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
destructive = Color(0xFFE57368),

View File

@@ -3,26 +3,36 @@ package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Glass surface defaults (UI-SPEC § Glass / Layout).
* Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
* floating button (plan 02.1-05).
*/
public data class RecipeGlass(
val borderWidth: Dp,
val shadowOffsetY: Dp,
val shadowBlur: Dp,
val shadowAlphaLight: Float,
val shadowAlphaDark: Float,
val blurRadius: Dp,
)
data object RecipeGlass {
val menu: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.10f,
curve = 0.5f,
edge = 0.04f,
dispersion = 0.05f,
saturation = 0.5f,
contrast = 1.3f,
frost = 15.dp,
)
public val DefaultRecipeGlass: RecipeGlass =
RecipeGlass(
borderWidth = 1.dp,
shadowOffsetY = 8.dp,
shadowBlur = 24.dp,
shadowAlphaLight = 0.12f,
shadowAlphaDark = 0.0f,
blurRadius = 24.dp,
)
val dockPress: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.20f,
curve = 0.05f,
edge = 0.04f,
dispersion = 0.03f,
saturation = 0.6f,
contrast = 1.8f,
frost = 0.dp,
)
}
data class RecipeGlassStyle(
val refraction: Float,
val curve: Float,
val edge: Float,
val dispersion: Float,
val saturation: Float,
val contrast: Float,
val frost: Dp,
)

View File

@@ -25,9 +25,6 @@ public val LocalRecipeSpacing: ProvidableCompositionLocal<RecipeSpacing> =
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") }
@Composable
public fun RecipeTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
@@ -38,29 +35,27 @@ public fun RecipeTheme(content: @Composable () -> Unit) {
LocalRecipeTypography provides DefaultRecipeTypography,
LocalRecipeSpacing provides DefaultRecipeSpacing,
LocalRecipeShapes provides DefaultRecipeShapes,
LocalRecipeGlass provides DefaultRecipeGlass,
content = content,
)
}
public object RecipeTheme {
public val colors: RecipeColors
object RecipeTheme {
val colors: RecipeColors
@Composable @ReadOnlyComposable
get() = LocalRecipeColors.current
public val typography: RecipeTypography
val typography: RecipeTypography
@Composable @ReadOnlyComposable
get() = LocalRecipeTypography.current
public val spacing: RecipeSpacing
val spacing: RecipeSpacing
@Composable @ReadOnlyComposable
get() = LocalRecipeSpacing.current
public val shapes: RecipeShapes
val shapes: RecipeShapes
@Composable @ReadOnlyComposable
get() = LocalRecipeShapes.current
public val glass: RecipeGlass
@Composable @ReadOnlyComposable
get() = LocalRecipeGlass.current
val glass: RecipeGlass
get() = RecipeGlass
}

View File

@@ -14,6 +14,7 @@ koin = "4.2.1"
koin-plugin = "1.0.0-RC2"
kotlin = "2.3.20"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.2"
kotlinx-serialization = "1.7.3"
ktor = "3.4.2"
lokksmith = "0.13.0"
@@ -33,6 +34,7 @@ kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", versio
# kotlinx.serialization (shared DTOs — D-27)
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }