Compare commits
7 Commits
48b41fd4af
...
f1e391ccda
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e391ccda | |||
| 488509db06 | |||
| ab1630a06b | |||
| fb00df856a | |||
| 8eda4b04ee | |||
| 8700d197f0 | |||
| ac5bfbc423 |
39
.planning/notes/nav-search-as-catalog.md
Normal file
39
.planning/notes/nav-search-as-catalog.md
Normal 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.
|
||||||
36
.planning/seeds/home-tab-content.md
Normal file
36
.planning/seeds/home-tab-content.md
Normal 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.
|
||||||
29
.planning/todos/pending/decide-add-recipe-placement.md
Normal file
29
.planning/todos/pending/decide-add-recipe-placement.md
Normal 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.
|
||||||
@@ -89,6 +89,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.clientLogging)
|
implementation(libs.ktor.clientLogging)
|
||||||
implementation(libs.ktor.serializationKotlinxJsonMpp)
|
implementation(libs.ktor.serializationKotlinxJsonMpp)
|
||||||
implementation(libs.kotlinx.serializationJson)
|
implementation(libs.kotlinx.serializationJson)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.multiplatform.settings)
|
implementation(libs.multiplatform.settings)
|
||||||
implementation(libs.lokksmith.compose)
|
implementation(libs.lokksmith.compose)
|
||||||
implementation(libs.navigation3.ui)
|
implementation(libs.navigation3.ui)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
|
<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) -->
|
<!-- 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_planner">Planer</string>
|
||||||
<string name="shell_tab_recipes">Przepisy</string>
|
|
||||||
<string name="shell_tab_pantry">Spiżarnia</string>
|
<string name="shell_tab_pantry">Spiżarnia</string>
|
||||||
<string name="shell_tab_shopping">Zakupy</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) -->
|
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
||||||
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
||||||
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
|
|
||||||
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
|
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
|
||||||
<string name="search_clear_a11y">Wyczyść</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) -->
|
<!-- 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_title">Twój plan tygodnia czeka</string>
|
||||||
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</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_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_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>
|
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package dev.ulfrx.recipe.di
|
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.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
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.search.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
@@ -14,8 +14,8 @@ val shellModule =
|
|||||||
// Active-tab tracking lives in TabNavigator (a `remember`-scoped state holder
|
// 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
|
// owned by AppShell), not in a shell-level VM, so there is no ShellViewModel
|
||||||
// to register.
|
// to register.
|
||||||
|
viewModel<HomeViewModel>()
|
||||||
viewModel<PlannerViewModel>()
|
viewModel<PlannerViewModel>()
|
||||||
viewModel<RecipesViewModel>()
|
|
||||||
viewModel<PantryViewModel>()
|
viewModel<PantryViewModel>()
|
||||||
viewModel<ShoppingViewModel>()
|
viewModel<ShoppingViewModel>()
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,33 @@
|
|||||||
package dev.ulfrx.recipe.navigation
|
package dev.ulfrx.recipe.navigation
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.composables.icons.lucide.BookOpenText
|
|
||||||
import com.composables.icons.lucide.CalendarDays
|
import com.composables.icons.lucide.CalendarDays
|
||||||
|
import com.composables.icons.lucide.House
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Package
|
import com.composables.icons.lucide.Package
|
||||||
import com.composables.icons.lucide.ShoppingCart
|
import com.composables.icons.lucide.ShoppingCart
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
|
import recipe.composeapp.generated.resources.shell_tab_home
|
||||||
import recipe.composeapp.generated.resources.shell_tab_pantry
|
import recipe.composeapp.generated.resources.shell_tab_pantry
|
||||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
|
||||||
import recipe.composeapp.generated.resources.shell_tab_shopping
|
import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||||
|
|
||||||
/**
|
enum class DockDestination(
|
||||||
* The 4 bottom-bar destinations in left→right 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(
|
|
||||||
val startDestination: Screen,
|
val startDestination: Screen,
|
||||||
val labelRes: StringResource,
|
val labelRes: StringResource,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
) {
|
) {
|
||||||
|
Home(
|
||||||
|
startDestination = Screen.Home.Root,
|
||||||
|
labelRes = Res.string.shell_tab_home,
|
||||||
|
icon = Lucide.House,
|
||||||
|
),
|
||||||
Planner(
|
Planner(
|
||||||
startDestination = Screen.Planner.Home,
|
startDestination = Screen.Planner.Home,
|
||||||
labelRes = Res.string.shell_tab_planner,
|
labelRes = Res.string.shell_tab_planner,
|
||||||
icon = Lucide.CalendarDays,
|
icon = Lucide.CalendarDays,
|
||||||
),
|
),
|
||||||
Recipes(
|
|
||||||
startDestination = Screen.Recipes.Home,
|
|
||||||
labelRes = Res.string.shell_tab_recipes,
|
|
||||||
icon = Lucide.BookOpenText,
|
|
||||||
),
|
|
||||||
Pantry(
|
Pantry(
|
||||||
startDestination = Screen.Pantry.Home,
|
startDestination = Screen.Pantry.Home,
|
||||||
labelRes = Res.string.shell_tab_pantry,
|
labelRes = Res.string.shell_tab_pantry,
|
||||||
@@ -55,7 +41,6 @@ enum class BottomBarDestination(
|
|||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Default landing tab — CONTEXT D-03. */
|
val Default: DockDestination = Home
|
||||||
val Default: BottomBarDestination = Planner
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,12 +10,12 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.ui.NavDisplay
|
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.PantryScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
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.ShoppingScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@@ -71,15 +71,16 @@ fun RootNavDisplay(
|
|||||||
backStack = navigator.backStackFor(tab),
|
backStack = navigator.backStackFor(tab),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onBack = { navigator.goBack(tab) },
|
onBack = { navigator.goBack(tab) },
|
||||||
entryProvider = entryProvider {
|
entryProvider =
|
||||||
|
entryProvider {
|
||||||
|
entry<Screen.Home.Root> {
|
||||||
|
val vm: HomeViewModel = koinViewModel()
|
||||||
|
HomeScreen(viewModel = vm)
|
||||||
|
}
|
||||||
entry<Screen.Planner.Home> {
|
entry<Screen.Planner.Home> {
|
||||||
val vm: PlannerViewModel = koinViewModel()
|
val vm: PlannerViewModel = koinViewModel()
|
||||||
PlannerScreen(viewModel = vm)
|
PlannerScreen(viewModel = vm)
|
||||||
}
|
}
|
||||||
entry<Screen.Recipes.Home> {
|
|
||||||
val vm: RecipesViewModel = koinViewModel()
|
|
||||||
RecipesScreen(viewModel = vm)
|
|
||||||
}
|
|
||||||
entry<Screen.Pantry.Home> {
|
entry<Screen.Pantry.Home> {
|
||||||
val vm: PantryViewModel = koinViewModel()
|
val vm: PantryViewModel = koinViewModel()
|
||||||
PantryScreen(viewModel = vm)
|
PantryScreen(viewModel = vm)
|
||||||
|
|||||||
@@ -7,22 +7,25 @@ import kotlinx.serialization.Serializable
|
|||||||
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
|
* 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).
|
* 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
|
* Screens are grouped by tab so future detail destinations slot in without
|
||||||
* without polluting the top-level namespace — e.g. `Screen.Recipes.Detail(id)`.
|
* polluting the top-level namespace — e.g. `Screen.Pantry.Detail(id)`. The
|
||||||
* The grouping is purely a code-organisation convenience; Nav 3 treats each
|
* grouping is purely a code-organisation convenience; Nav 3 treats each leaf as
|
||||||
* leaf as an independent NavKey regardless of nesting.
|
* 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 Screen : NavKey {
|
||||||
|
sealed interface Home : Screen {
|
||||||
|
@Serializable
|
||||||
|
data object Root : Home
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface Planner : Screen {
|
sealed interface Planner : Screen {
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Home : Planner
|
data object Home : Planner
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface Recipes : Screen {
|
|
||||||
@Serializable
|
|
||||||
data object Home : Recipes
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface Pantry : Screen {
|
sealed interface Pantry : Screen {
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Home : Pantry
|
data object Home : Pantry
|
||||||
|
|||||||
@@ -2,28 +2,27 @@ package dev.ulfrx.recipe.navigation
|
|||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class TabNavigator(
|
class TabNavigator(
|
||||||
initialTab: BottomBarDestination = BottomBarDestination.Default,
|
initialTab: DockDestination = DockDestination.Default,
|
||||||
) {
|
) {
|
||||||
private val backStacks: Map<BottomBarDestination, SnapshotStateList<Screen>> =
|
private val backStacks: Map<DockDestination, SnapshotStateList<Screen>> =
|
||||||
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
||||||
|
|
||||||
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
|
var activeTab: DockDestination by mutableStateOf(initialTab)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val activeBackStack: SnapshotStateList<Screen>
|
val activeBackStack: SnapshotStateList<Screen>
|
||||||
get() = backStacks.getValue(activeTab)
|
get() = backStacks.getValue(activeTab)
|
||||||
|
|
||||||
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
|
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = backStacks.getValue(tab)
|
||||||
backStacks.getValue(tab)
|
|
||||||
|
|
||||||
fun selectTab(tab: BottomBarDestination) {
|
fun selectTab(tab: DockDestination) {
|
||||||
if (tab == activeTab) {
|
if (tab == activeTab) {
|
||||||
popToRoot(tab)
|
popToRoot(tab)
|
||||||
} else {
|
} else {
|
||||||
@@ -35,14 +34,14 @@ class TabNavigator(
|
|||||||
activeBackStack.add(screen)
|
activeBackStack.add(screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goBack(tab: BottomBarDestination = activeTab) {
|
fun goBack(tab: DockDestination = activeTab) {
|
||||||
val stack = backStacks.getValue(tab)
|
val stack = backStacks.getValue(tab)
|
||||||
if (stack.size > 1) {
|
if (stack.size > 1) {
|
||||||
stack.removeAt(stack.lastIndex)
|
stack.removeAt(stack.lastIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun popToRoot(tab: BottomBarDestination) {
|
private fun popToRoot(tab: DockDestination) {
|
||||||
val stack = backStacks.getValue(tab)
|
val stack = backStacks.getValue(tab)
|
||||||
while (stack.size > 1) {
|
while (stack.size > 1) {
|
||||||
stack.removeAt(stack.lastIndex)
|
stack.removeAt(stack.lastIndex)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,22 +24,6 @@ import com.composeunstyled.UnstyledButton
|
|||||||
import com.composeunstyled.UnstyledIcon
|
import com.composeunstyled.UnstyledIcon
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
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
|
@Composable
|
||||||
fun CircleGlassButton(
|
fun CircleGlassButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|||||||
@@ -3,48 +3,40 @@ package dev.ulfrx.recipe.ui.components.glass
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Stable
|
||||||
class GlassBackdropState internal constructor(
|
class GlassBackdropState internal constructor(
|
||||||
internal val liquidState: Any,
|
internal val liquidState: LiquidState,
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberGlassBackdropState(): GlassBackdropState {
|
fun rememberGlassBackdropState(): GlassBackdropState {
|
||||||
val liquidState = rememberLiquidBackdropHandle()
|
val liquidState = rememberLiquidState()
|
||||||
return remember(liquidState) {
|
return remember(liquidState) {
|
||||||
GlassBackdropState(
|
GlassBackdropState(liquidState)
|
||||||
liquidState = liquidState,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GlassBackdropSource(
|
fun GlassBackdropSource(
|
||||||
|
state: GlassBackdropState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
state: GlassBackdropState = rememberGlassBackdropState(),
|
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalGlassBackdropState provides state) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = modifier.liquefiable(state.liquidState),
|
||||||
modifier
|
|
||||||
.liquidBackdropSource(state),
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,55 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.glass
|
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.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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 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
|
@Composable
|
||||||
fun GlassSurface(
|
fun GlassSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
tint: Color = RecipeTheme.colors.surfaceGlass,
|
tint: Color = RecipeTheme.colors.surfaceGlass,
|
||||||
cornerRadius: Dp = 28.dp,
|
cornerRadius: Dp = 28.dp,
|
||||||
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
glassStyle: RecipeGlassStyle = RecipeTheme.glass.menu,
|
||||||
edgeIntensity: Float = 0.05f,
|
recordAsSource: Boolean = false,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val backdropState = LocalGlassBackdropState.current
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,26 +32,6 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
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
|
@Composable
|
||||||
fun GlassTextField(
|
fun GlassTextField(
|
||||||
value: String,
|
value: String,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -14,24 +14,17 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.empty_recipes_subtitle
|
import recipe.composeapp.generated.resources.empty_home_subtitle
|
||||||
import recipe.composeapp.generated.resources.empty_recipes_title
|
import recipe.composeapp.generated.resources.empty_home_title
|
||||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
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
|
@Composable
|
||||||
fun RecipesScreen(viewModel: RecipesViewModel) {
|
fun HomeScreen(viewModel: HomeViewModel) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -47,15 +40,15 @@ fun RecipesScreen(viewModel: RecipesViewModel) {
|
|||||||
verticalArrangement = Arrangement.Top,
|
verticalArrangement = Arrangement.Top,
|
||||||
) {
|
) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = stringResource(Res.string.shell_tab_recipes),
|
text = stringResource(Res.string.shell_tab_home),
|
||||||
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
|
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
|
||||||
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
|
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = BottomBarDestination.Recipes.icon,
|
icon = DockDestination.Home.icon,
|
||||||
title = stringResource(Res.string.empty_recipes_title),
|
title = stringResource(Res.string.empty_home_title),
|
||||||
subtitle = stringResource(Res.string.empty_recipes_subtitle),
|
subtitle = stringResource(Res.string.empty_home_subtitle),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -52,7 +52,7 @@ fun PantryScreen(viewModel: PantryViewModel) {
|
|||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = BottomBarDestination.Pantry.icon,
|
icon = DockDestination.Pantry.icon,
|
||||||
title = stringResource(Res.string.empty_pantry_title),
|
title = stringResource(Res.string.empty_pantry_title),
|
||||||
subtitle = stringResource(Res.string.empty_pantry_subtitle),
|
subtitle = stringResource(Res.string.empty_pantry_subtitle),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,197 +1,81 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.planner
|
package dev.ulfrx.recipe.ui.screens.planner
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
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.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
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 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 dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
|
* Phase 2.1 — planner shell with the shared calendar at the top. Phase 6 fills
|
||||||
* empty body with the calendar grid.
|
* in the area below the calendar with meal slots driven by [PlannerState.selectedDate].
|
||||||
*
|
*
|
||||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlannerScreen(viewModel: PlannerViewModel) {
|
fun PlannerScreen(viewModel: PlannerViewModel) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val today = remember { todayInSystemTz() }
|
||||||
val bgDark = Color(0xFF14181F)
|
|
||||||
val titleColor = Color(0xFFE8E4DC)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.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(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(
|
.windowInsetsPadding(WindowInsets.statusBars),
|
||||||
start = 12.dp + 6.dp,
|
|
||||||
end = 12.dp,
|
|
||||||
top = 12.dp,
|
|
||||||
bottom = 12.dp,
|
|
||||||
),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
|
||||||
) {
|
) {
|
||||||
// Title bar
|
BasicText(
|
||||||
Box(
|
text = stringResource(Res.string.shell_tab_planner),
|
||||||
|
style =
|
||||||
|
RecipeTheme.typography.title.copy(
|
||||||
|
color = RecipeTheme.colors.content,
|
||||||
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier.padding(
|
||||||
.fillMaxWidth(item.titleWeight)
|
top = RecipeTheme.spacing.xl,
|
||||||
.height(14.dp)
|
start = RecipeTheme.spacing.lg,
|
||||||
.clip(RoundedCornerShape(4.dp))
|
end = RecipeTheme.spacing.lg,
|
||||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
|
),
|
||||||
)
|
)
|
||||||
// Subtitle bar
|
|
||||||
Box(
|
Spacer(modifier = Modifier.height(RecipeTheme.spacing.lg))
|
||||||
modifier =
|
|
||||||
Modifier
|
SwipeableCalendar(
|
||||||
.fillMaxWidth(item.subtitleWeight)
|
selectedDate = state.selectedDate,
|
||||||
.height(10.dp)
|
today = today,
|
||||||
.clip(RoundedCornerShape(3.dp))
|
mode = state.calendarMode,
|
||||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
|
onSelectDate = viewModel::selectDate,
|
||||||
)
|
onModeChange = viewModel::setCalendarMode,
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
// Swipe auto-follows: dropping into a new week/month bumps
|
||||||
// Faint metadata dot + bar
|
// the selection by the same offset (kotlinx.datetime clamps
|
||||||
Box(
|
// day-of-month for short months).
|
||||||
modifier =
|
onVisibleAnchorChange = viewModel::selectDate,
|
||||||
Modifier
|
expandable = true,
|
||||||
.fillMaxWidth(0.18f)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.height(8.dp)
|
|
||||||
.clip(RoundedCornerShape(2.dp))
|
|
||||||
.background(item.accent.copy(alpha = 0.55f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(20.dp)
|
|
||||||
.padding(end = 0.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.planner
|
package dev.ulfrx.recipe.ui.screens.planner
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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
|
* UI state for [PlannerScreen]. Phase 2.1 ships only the calendar; Phase 6
|
||||||
* (Meal Planner — Core Write Path) extends this with calendar data + actions.
|
* extends this with day-plan data, meal slot actions, and pantry/shortfall
|
||||||
|
* derivations driven by [selectedDate].
|
||||||
*/
|
*/
|
||||||
data class PlannerState(
|
data class PlannerState(
|
||||||
val isEmpty: Boolean = true,
|
val selectedDate: LocalDate,
|
||||||
|
val calendarMode: CalendarMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlannerViewModel : ViewModel() {
|
class PlannerViewModel : ViewModel() {
|
||||||
private val _state = MutableStateFlow(PlannerState())
|
private val _state =
|
||||||
|
MutableStateFlow(
|
||||||
|
PlannerState(
|
||||||
|
selectedDate = todayInSystemTz(),
|
||||||
|
calendarMode = CalendarMode.Week,
|
||||||
|
),
|
||||||
|
)
|
||||||
val state: StateFlow<PlannerState> = _state.asStateFlow()
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.search
|
package dev.ulfrx.recipe.ui.screens.search
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|||||||
@@ -1,95 +1,40 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.shell
|
package dev.ulfrx.recipe.ui.screens.shell
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
|
||||||
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
||||||
import dev.ulfrx.recipe.navigation.TabNavigator
|
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.GlassBackdropSource
|
||||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||||
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
|
||||||
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AppShell(modifier: Modifier = Modifier) {
|
fun AppShell(modifier: Modifier = Modifier) {
|
||||||
val navigator = remember { TabNavigator() }
|
val navigator = remember { TabNavigator() }
|
||||||
val searchVm: ShellSearchViewModel = koinViewModel()
|
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||||
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
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()
|
val backdropState = rememberGlassBackdropState()
|
||||||
|
|
||||||
BackHandler(enabled = searchState.isOpen) {
|
|
||||||
// Blocked — user must exit search via explicit affordance (dock icon or X).
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -97,7 +42,6 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(RecipeTheme.colors.background),
|
.background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
// Body — cross-fade between the tab stack and the search overlay.
|
|
||||||
GlassBackdropSource(
|
GlassBackdropSource(
|
||||||
state = backdropState,
|
state = backdropState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -122,115 +66,20 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom chrome — Apple-Music-style: don't respect the full nav-bar
|
ShellBottomChrome(
|
||||||
// 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",
|
|
||||||
)
|
|
||||||
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,
|
activeTab = navigator.activeTab,
|
||||||
onTabSelect = navigator::selectTab,
|
onTabSelect = navigator::selectTab,
|
||||||
onSearchTap = searchVm::open,
|
search =
|
||||||
|
SearchHandlers(
|
||||||
|
state = searchState,
|
||||||
|
onOpen = searchVm::open,
|
||||||
|
onQueryChange = searchVm::onQueryChange,
|
||||||
|
onClose = searchVm::close,
|
||||||
|
onFocus = searchVm::focus,
|
||||||
|
onUnfocus = searchVm::unfocus,
|
||||||
|
),
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -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.AnimatedVisibility
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
@@ -20,9 +20,9 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.DockDestination
|
||||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
|
||||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
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 dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
@@ -53,7 +53,7 @@ fun SearchPillRow(
|
|||||||
query: String,
|
query: String,
|
||||||
isFocused: Boolean,
|
isFocused: Boolean,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
activeTab: BottomBarDestination,
|
activeTab: DockDestination,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onFocusGained: () -> Unit,
|
onFocusGained: () -> Unit,
|
||||||
@@ -98,11 +98,12 @@ fun SearchPillRow(
|
|||||||
exit = sideButtonExit,
|
exit = sideButtonExit,
|
||||||
) {
|
) {
|
||||||
DockBar(
|
DockBar(
|
||||||
destinations = BottomBarDestination.entries,
|
destinations = DockDestination.entries,
|
||||||
active = activeTab,
|
active = activeTab,
|
||||||
collapsed = true,
|
collapsed = true,
|
||||||
onTabSelect = { /* unreachable while collapsed */ },
|
// Collapsed dock only emits a re-select of the active tab,
|
||||||
onCollapsedTap = onClose,
|
// which here means "close the search overlay".
|
||||||
|
onTabSelect = { onClose() },
|
||||||
height = pillHeight,
|
height = pillHeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
* Shell-wide search state shape, exposed by
|
||||||
@@ -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.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -52,7 +52,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
|||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = BottomBarDestination.Shopping.icon,
|
icon = DockDestination.Shopping.icon,
|
||||||
title = stringResource(Res.string.empty_shopping_title),
|
title = stringResource(Res.string.empty_shopping_title),
|
||||||
subtitle = stringResource(Res.string.empty_shopping_subtitle),
|
subtitle = stringResource(Res.string.empty_shopping_subtitle),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ public data class RecipeColors(
|
|||||||
val background: Color,
|
val background: Color,
|
||||||
val surface: Color,
|
val surface: Color,
|
||||||
val surfaceGlass: Color,
|
val surfaceGlass: Color,
|
||||||
|
val surfaceGlassOverlay: Color,
|
||||||
val content: Color,
|
val content: Color,
|
||||||
val contentMuted: Color,
|
val contentMuted: Color,
|
||||||
val accent: Color,
|
val accent: Color,
|
||||||
|
val chromeActive: Color,
|
||||||
val separator: Color,
|
val separator: Color,
|
||||||
val borderCard: Color,
|
val borderCard: Color,
|
||||||
val destructive: Color,
|
val destructive: Color,
|
||||||
@@ -20,12 +22,14 @@ public data class RecipeColors(
|
|||||||
|
|
||||||
public val LightRecipeColors: RecipeColors =
|
public val LightRecipeColors: RecipeColors =
|
||||||
RecipeColors(
|
RecipeColors(
|
||||||
background = Color(0xFFF7F5F1),
|
background = Color(0xFFEAE6DF),
|
||||||
surface = Color(0xFFFFFFFF),
|
surface = Color(0xFFFFFFFF),
|
||||||
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f),
|
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f),
|
||||||
|
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.20f),
|
||||||
content = Color(0xFF0F1113),
|
content = Color(0xFF0F1113),
|
||||||
contentMuted = Color(0xFF6B6E73),
|
contentMuted = Color(0xFF6B6E73),
|
||||||
accent = Color(0xFFD97757),
|
accent = Color(0xFFD97757),
|
||||||
|
chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f),
|
||||||
separator = Color(0xFFE5E1DA),
|
separator = Color(0xFFE5E1DA),
|
||||||
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
|
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
|
||||||
destructive = Color(0xFFC0392B),
|
destructive = Color(0xFFC0392B),
|
||||||
@@ -35,10 +39,12 @@ public val DarkRecipeColors: RecipeColors =
|
|||||||
RecipeColors(
|
RecipeColors(
|
||||||
background = Color(0xFF0F1113),
|
background = Color(0xFF0F1113),
|
||||||
surface = Color(0xFF1A1D21),
|
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),
|
content = Color(0xFFF1EFEA),
|
||||||
contentMuted = Color(0xFF9AA0A6),
|
contentMuted = Color(0xFF9AA0A6),
|
||||||
accent = Color(0xFFE48A6E),
|
accent = Color(0xFFE48A6E),
|
||||||
|
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
|
||||||
separator = Color(0xFF2A2D31),
|
separator = Color(0xFF2A2D31),
|
||||||
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
||||||
destructive = Color(0xFFE57368),
|
destructive = Color(0xFFE57368),
|
||||||
|
|||||||
@@ -3,26 +3,36 @@ package dev.ulfrx.recipe.ui.theme
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
data object RecipeGlass {
|
||||||
* Glass surface defaults (UI-SPEC § Glass / Layout).
|
val menu: RecipeGlassStyle =
|
||||||
* Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
|
RecipeGlassStyle(
|
||||||
* floating button (plan 02.1-05).
|
refraction = 0.10f,
|
||||||
*/
|
curve = 0.5f,
|
||||||
public data class RecipeGlass(
|
edge = 0.04f,
|
||||||
val borderWidth: Dp,
|
dispersion = 0.05f,
|
||||||
val shadowOffsetY: Dp,
|
saturation = 0.5f,
|
||||||
val shadowBlur: Dp,
|
contrast = 1.3f,
|
||||||
val shadowAlphaLight: Float,
|
frost = 15.dp,
|
||||||
val shadowAlphaDark: Float,
|
|
||||||
val blurRadius: Dp,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
public val DefaultRecipeGlass: RecipeGlass =
|
val dockPress: RecipeGlassStyle =
|
||||||
RecipeGlass(
|
RecipeGlassStyle(
|
||||||
borderWidth = 1.dp,
|
refraction = 0.20f,
|
||||||
shadowOffsetY = 8.dp,
|
curve = 0.05f,
|
||||||
shadowBlur = 24.dp,
|
edge = 0.04f,
|
||||||
shadowAlphaLight = 0.12f,
|
dispersion = 0.03f,
|
||||||
shadowAlphaDark = 0.0f,
|
saturation = 0.6f,
|
||||||
blurRadius = 24.dp,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ public val LocalRecipeSpacing: ProvidableCompositionLocal<RecipeSpacing> =
|
|||||||
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
|
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
|
||||||
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
|
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
|
||||||
|
|
||||||
public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
|
|
||||||
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
public fun RecipeTheme(content: @Composable () -> Unit) {
|
public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -38,29 +35,27 @@ public fun RecipeTheme(content: @Composable () -> Unit) {
|
|||||||
LocalRecipeTypography provides DefaultRecipeTypography,
|
LocalRecipeTypography provides DefaultRecipeTypography,
|
||||||
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
||||||
LocalRecipeShapes provides DefaultRecipeShapes,
|
LocalRecipeShapes provides DefaultRecipeShapes,
|
||||||
LocalRecipeGlass provides DefaultRecipeGlass,
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public object RecipeTheme {
|
object RecipeTheme {
|
||||||
public val colors: RecipeColors
|
val colors: RecipeColors
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeColors.current
|
get() = LocalRecipeColors.current
|
||||||
|
|
||||||
public val typography: RecipeTypography
|
val typography: RecipeTypography
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeTypography.current
|
get() = LocalRecipeTypography.current
|
||||||
|
|
||||||
public val spacing: RecipeSpacing
|
val spacing: RecipeSpacing
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeSpacing.current
|
get() = LocalRecipeSpacing.current
|
||||||
|
|
||||||
public val shapes: RecipeShapes
|
val shapes: RecipeShapes
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeShapes.current
|
get() = LocalRecipeShapes.current
|
||||||
|
|
||||||
public val glass: RecipeGlass
|
val glass: RecipeGlass
|
||||||
@Composable @ReadOnlyComposable
|
get() = RecipeGlass
|
||||||
get() = LocalRecipeGlass.current
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ koin = "4.2.1"
|
|||||||
koin-plugin = "1.0.0-RC2"
|
koin-plugin = "1.0.0-RC2"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.3.20"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
|
kotlinx-datetime = "0.6.2"
|
||||||
kotlinx-serialization = "1.7.3"
|
kotlinx-serialization = "1.7.3"
|
||||||
ktor = "3.4.2"
|
ktor = "3.4.2"
|
||||||
lokksmith = "0.13.0"
|
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.serialization (shared DTOs — D-27)
|
||||||
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
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" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
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" }
|
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
|
|||||||
Reference in New Issue
Block a user