Compare commits
19 Commits
48b41fd4af
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 11ea98e452 | |||
| bcd9b329c5 | |||
| 4dd8ef5f8a | |||
| d1916d3fe6 | |||
| 121f79109a | |||
| 22b43050d6 | |||
| 579504b927 | |||
| c017a8e777 | |||
| 6d38b8b775 | |||
| ae4186d9fa | |||
| 2d2556fd26 | |||
| 815c4f4efc | |||
| f1e391ccda | |||
| 488509db06 | |||
| ab1630a06b | |||
| fb00df856a | |||
| 8eda4b04ee | |||
| 8700d197f0 | |||
| ac5bfbc423 |
@@ -74,14 +74,16 @@ dev.ulfrx.recipe/
|
||||
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
|
||||
├── ui/
|
||||
│ ├── theme/ # Colors, typography, Liquid glass style tokens
|
||||
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
|
||||
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
|
||||
│ ├── components/ # Shared, stateless (VM-free) Recipe-styled composables built on Compose Unstyled where useful
|
||||
│ └── screens/{recipes,planner,pantry,shopping,recipedetail}/ # Each with screen + ViewModel
|
||||
├── data/{local,remote,repository}/
|
||||
└── domain/ # Client-only logic; shared/ handles cross-cutting
|
||||
```
|
||||
|
||||
**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above.
|
||||
|
||||
**Rule:** A `screens/` package is a *stateful* UI feature (screen + ViewModel), not necessarily a nav route. `recipedetail` presents as a modal bottom sheet and is opened from multiple hosts (search, later planner) — it lives under `screens/` because it owns a ViewModel, while its leaf widgets (`IngredientRow`, `NutritionSummary`) stay in `components/`, which is reserved for stateless, VM-free composables.
|
||||
|
||||
## Non-negotiable conventions
|
||||
|
||||
1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor.
|
||||
|
||||
@@ -89,9 +89,11 @@ kotlin {
|
||||
implementation(libs.ktor.clientLogging)
|
||||
implementation(libs.ktor.serializationKotlinxJsonMpp)
|
||||
implementation(libs.kotlinx.serializationJson)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.multiplatform.settings)
|
||||
implementation(libs.lokksmith.compose)
|
||||
implementation(libs.navigation3.ui)
|
||||
implementation(libs.androidx.lifecycle.viewmodelNavigation3)
|
||||
implementation(libs.compose.unstyled)
|
||||
implementation(libs.compose.icons.lucide)
|
||||
implementation(libs.liquid)
|
||||
@@ -107,6 +109,9 @@ kotlin {
|
||||
// ASWebAuthenticationSession integration directly from Kotlin.
|
||||
implementation(libs.ktor.clientDarwin)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.ulfrx.recipe.ui.keyboard
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
internal actual fun rememberKeyboardTransitionState(): KeyboardTransitionState {
|
||||
val imeInset = WindowInsets.ime.asPaddingValues().calculateBottomPadding()
|
||||
return KeyboardTransitionState(
|
||||
currentInset = imeInset,
|
||||
targetInset = imeInset,
|
||||
animationDurationMillis = AndroidKeyboardAnimationDurationMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private const val AndroidKeyboardAnimationDurationMillis = 250
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
@@ -14,33 +14,92 @@
|
||||
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
|
||||
|
||||
<!-- Phase 2.1 — App shell navigation tab labels (UI-03, CONTEXT D-03) -->
|
||||
<string name="shell_tab_home">Start</string>
|
||||
<string name="shell_tab_planner">Planer</string>
|
||||
<string name="shell_tab_recipes">Przepisy</string>
|
||||
<string name="shell_tab_pantry">Spiżarnia</string>
|
||||
<string name="shell_tab_shopping">Zakupy</string>
|
||||
|
||||
<!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
|
||||
<string name="search_placeholder">Szukaj…</string>
|
||||
|
||||
<!-- Phase 2.1 — Search screen scaffolding (curated landing in State B; results in State C) -->
|
||||
<string name="search_screen_curated_title">Tu pojawią się szybkie skróty</string>
|
||||
<string name="search_screen_curated_subtitle">Sugestie wyszukiwania pojawią się wraz z rozwojem aplikacji.</string>
|
||||
<!-- Phase 2.1 — Search screen scaffolding (results in State C) -->
|
||||
<string name="search_screen_empty_results_title">Brak wyników</string>
|
||||
<string name="search_screen_empty_results_subtitle">Zacznij pisać, aby wyszukać.</string>
|
||||
|
||||
<!-- Phase 2.1 — Recipe catalog card meta (Phase 11 polishes copy + plurals) -->
|
||||
<string name="recipe_card_minutes_format">%1$d min</string>
|
||||
<string name="recipe_card_kcal_format">%1$d kcal</string>
|
||||
|
||||
<!-- Phase 2.1 — Nutrition facts widget (reusable across recipe detail, planner, …) -->
|
||||
<string name="nutrition_label">Wartości odżywcze</string>
|
||||
<string name="nutrition_macro_kcal">kcal</string>
|
||||
<string name="nutrition_macro_protein">białko</string>
|
||||
<string name="nutrition_macro_fat">tłuszcz</string>
|
||||
<string name="nutrition_macro_carbs">węglowodany</string>
|
||||
<string name="nutrition_grams_format">%1$dg</string>
|
||||
|
||||
<!-- Phase 2.1 — Ingredient row widget (reusable across recipe detail, planner, …) -->
|
||||
<string name="ingredient_substitute_a11y">Zamień składnik</string>
|
||||
|
||||
<!-- Phase 2.1 — Recipe detail sheet (UI-only view; planner wiring lands in later phases) -->
|
||||
<string name="recipe_detail_servings_label">Porcje</string>
|
||||
<string name="recipe_detail_section_ingredients">Składniki</string>
|
||||
<string name="recipe_detail_section_steps">Kroki</string>
|
||||
<string name="recipe_detail_step_number_format">%1$d.</string>
|
||||
<string name="recipe_detail_servings_decrement_a11y">Zmniejsz liczbę porcji</string>
|
||||
<string name="recipe_detail_servings_increment_a11y">Zwiększ liczbę porcji</string>
|
||||
<string name="sheet_drag_handle_a11y">Przeciągnij w dół, aby zamknąć</string>
|
||||
<string name="recipe_detail_not_found">Nie znaleziono przepisu</string>
|
||||
<string name="meal_plan_editor_not_found">Nie udało się otworzyć edytora</string>
|
||||
|
||||
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
||||
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
||||
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
|
||||
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
|
||||
<string name="search_clear_a11y">Wyczyść</string>
|
||||
|
||||
<!-- Phase 2.1 — Dock a11y -->
|
||||
<string name="dock_expand_a11y">Rozwiń pasek nawigacji</string>
|
||||
|
||||
<!-- Phase 2.1 — Empty-state copy (UI-09, CONTEXT D-10/D-11/D-12) -->
|
||||
<string name="empty_home_title">Tu pojawi się Twój dzień</string>
|
||||
<string name="empty_home_subtitle">Wkrótce zobaczysz tu podsumowania i propozycje.</string>
|
||||
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
|
||||
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
|
||||
<string name="empty_recipes_title">Tu pojawi się Twoja książka kucharska</string>
|
||||
<string name="empty_recipes_subtitle">Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.</string>
|
||||
<string name="empty_pantry_title">Spiżarnia jest jeszcze pusta</string>
|
||||
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
|
||||
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
|
||||
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
|
||||
|
||||
<!-- Bottom calendar pill (planer / spiżarnia / zakupy) -->
|
||||
<string name="calendar_horizon_today">Tylko dziś</string>
|
||||
<string name="calendar_horizon_days">Najbliższe %1$d dni</string>
|
||||
|
||||
<!-- Dummy metryki pilla (UI-first; realne dane w fazach 8/9) -->
|
||||
<string name="pantry_shortfall_count">%1$d braków</string>
|
||||
<string name="shopping_buy_count">%1$d do kupienia</string>
|
||||
|
||||
<!-- Pory posiłku — wspólne dla detalu i edytora planu (Phase 6 polishes copy + plurals) -->
|
||||
<string name="meal_slot_breakfast">Śniadanie</string>
|
||||
<string name="meal_slot_lunch">Lunch</string>
|
||||
<string name="meal_slot_dinner">Obiad</string>
|
||||
<string name="meal_slot_supper">Kolacja</string>
|
||||
<string name="meal_slot_snack">Przekąska</string>
|
||||
|
||||
<!-- Phase 6 — Meal plan editor (UI-first; planStore wiring lands in later phases) -->
|
||||
<string name="meal_plan_editor_title">Zaplanuj posiłek</string>
|
||||
<string name="meal_plan_editor_title_a11y">Dodaj posiłek do planu</string>
|
||||
<string name="meal_plan_editor_back_a11y">Wróć do szczegółów przepisu</string>
|
||||
<string name="meal_plan_editor_confirm">Dodaj</string>
|
||||
<string name="meal_plan_editor_confirm_a11y">Dodaj posiłek do planu</string>
|
||||
<string name="meal_plan_editor_section_slot">Pora posiłku</string>
|
||||
<string name="meal_plan_editor_section_servings">Porcje</string>
|
||||
<string name="meal_plan_editor_section_ingredients">Składniki</string>
|
||||
<string name="meal_plan_editor_add_ingredient">Dodaj składnik</string>
|
||||
<string name="meal_plan_editor_add_ingredient_search_placeholder">Szukaj składnika…</string>
|
||||
<string name="meal_plan_editor_add_ingredient_cancel">Anuluj</string>
|
||||
<string name="meal_plan_editor_add_ingredient_empty">Brak wyników</string>
|
||||
<string name="meal_plan_editor_removed_format">%1$d usuniętych</string>
|
||||
<string name="meal_plan_editor_removed_restore">Przywróć</string>
|
||||
<string name="meal_plan_editor_remove_ingredient_a11y">Usuń składnik</string>
|
||||
<string name="meal_plan_editor_added_marker_a11y">Dodany składnik</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
package dev.ulfrx.recipe.di
|
||||
|
||||
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
|
||||
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipedetail.sampleRecipe
|
||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koin.plugin.module.dsl.viewModel
|
||||
|
||||
val shellModule =
|
||||
module {
|
||||
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
|
||||
// Active-tab tracking lives in TabNavigator (a `remember`-scoped state holder
|
||||
// owned by AppShell), not in a shell-level VM, so there is no ShellViewModel
|
||||
// to register.
|
||||
viewModel<HomeViewModel>()
|
||||
viewModel<PlannerViewModel>()
|
||||
viewModel<RecipesViewModel>()
|
||||
viewModel<PantryViewModel>()
|
||||
viewModel<ShoppingViewModel>()
|
||||
|
||||
// Shell-wide search VM — single global state machine (closed / open
|
||||
// unfocused / open focused) shared by the SearchScreen body and the
|
||||
// SearchPillRow chrome. Per-tab SearchViewModels were retired when search
|
||||
// moved from per-tab inline overlay to a shell-level destination.
|
||||
viewModel<ShellSearchViewModel>()
|
||||
viewModel<RecipeCatalogViewModel>()
|
||||
|
||||
viewModel { (recipeId: String) ->
|
||||
RecipeDetailViewModel(recipeId = recipeId)
|
||||
}
|
||||
viewModel { (source: MealPlanEditorSource) ->
|
||||
MealPlanEditorViewModel(
|
||||
source = source,
|
||||
recipeProvider = ::sampleRecipe,
|
||||
// Phase 6 swaps this for the real PlannedMealsRepository lookup.
|
||||
plannedMealProvider = { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,33 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.composables.icons.lucide.BookOpenText
|
||||
import com.composables.icons.lucide.CalendarDays
|
||||
import com.composables.icons.lucide.House
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Package
|
||||
import com.composables.icons.lucide.ShoppingCart
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.shell_tab_home
|
||||
import recipe.composeapp.generated.resources.shell_tab_pantry
|
||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
||||
import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||
|
||||
/**
|
||||
* The 4 bottom-bar destinations in 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(
|
||||
enum class DockDestination(
|
||||
val startDestination: Screen,
|
||||
val labelRes: StringResource,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Home(
|
||||
startDestination = Screen.Home.Root,
|
||||
labelRes = Res.string.shell_tab_home,
|
||||
icon = Lucide.House,
|
||||
),
|
||||
Planner(
|
||||
startDestination = Screen.Planner.Home,
|
||||
labelRes = Res.string.shell_tab_planner,
|
||||
icon = Lucide.CalendarDays,
|
||||
),
|
||||
Recipes(
|
||||
startDestination = Screen.Recipes.Home,
|
||||
labelRes = Res.string.shell_tab_recipes,
|
||||
icon = Lucide.BookOpenText,
|
||||
),
|
||||
Pantry(
|
||||
startDestination = Screen.Pantry.Home,
|
||||
labelRes = Res.string.shell_tab_pantry,
|
||||
@@ -55,7 +41,6 @@ enum class BottomBarDestination(
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Default landing tab — CONTEXT D-03. */
|
||||
val Default: BottomBarDestination = Planner
|
||||
val Default: DockDestination = Home
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface MealPlanEditorSource {
|
||||
@Serializable
|
||||
data class NewFromRecipe(
|
||||
val recipeId: String,
|
||||
val initialServings: Int = 1,
|
||||
val initialSubstitutions: Map<String, String> = emptyMap(),
|
||||
) : MealPlanEditorSource
|
||||
|
||||
@Serializable
|
||||
data class EditExistingPlan(val plannedMealId: String) : MealPlanEditorSource
|
||||
}
|
||||
@@ -10,12 +10,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dev.ulfrx.recipe.ui.screens.home.HomeScreen
|
||||
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
|
||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
@@ -45,9 +45,8 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
|
||||
* (`saveState=true`/`restoreState=true`).
|
||||
*
|
||||
* Phase 5+ introduces detail screens with their own VM scopes; at that point
|
||||
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
|
||||
* specifically (passed via `entryDecorators = listOf(...)`).
|
||||
* Recipe detail is a modal bottom sheet (not a nav destination), so it needs no
|
||||
* per-entry VM scope here; its VM is hosted by the surface that opens it.
|
||||
*
|
||||
* ## Search note
|
||||
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not
|
||||
@@ -71,24 +70,25 @@ fun RootNavDisplay(
|
||||
backStack = navigator.backStackFor(tab),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = { navigator.goBack(tab) },
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Planner.Home> {
|
||||
val vm: PlannerViewModel = koinViewModel()
|
||||
PlannerScreen(viewModel = vm)
|
||||
}
|
||||
entry<Screen.Recipes.Home> {
|
||||
val vm: RecipesViewModel = koinViewModel()
|
||||
RecipesScreen(viewModel = vm)
|
||||
}
|
||||
entry<Screen.Pantry.Home> {
|
||||
val vm: PantryViewModel = koinViewModel()
|
||||
PantryScreen(viewModel = vm)
|
||||
}
|
||||
entry<Screen.Shopping.Home> {
|
||||
val vm: ShoppingViewModel = koinViewModel()
|
||||
ShoppingScreen(viewModel = vm)
|
||||
}
|
||||
},
|
||||
entryProvider =
|
||||
entryProvider {
|
||||
entry<Screen.Home.Root> {
|
||||
val vm: HomeViewModel = koinViewModel()
|
||||
HomeScreen(viewModel = vm)
|
||||
}
|
||||
entry<Screen.Planner.Home> {
|
||||
val vm: PlannerViewModel = koinViewModel()
|
||||
PlannerScreen(viewModel = vm)
|
||||
}
|
||||
entry<Screen.Pantry.Home> {
|
||||
val vm: PantryViewModel = koinViewModel()
|
||||
PantryScreen(viewModel = vm)
|
||||
}
|
||||
entry<Screen.Shopping.Home> {
|
||||
val vm: ShoppingViewModel = koinViewModel()
|
||||
ShoppingScreen(viewModel = vm)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,21 @@ import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
|
||||
* back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration).
|
||||
*
|
||||
* Screens are grouped by tab so future detail destinations (Phase 5+) slot in
|
||||
* without polluting the top-level namespace — e.g. `Screen.Recipes.Detail(id)`.
|
||||
* The grouping is purely a code-organisation convenience; Nav 3 treats each
|
||||
* leaf as an independent NavKey regardless of nesting.
|
||||
* Each leaf is `@Serializable` because Nav 3 persists the back stack via
|
||||
* kotlinx-serialization (process-death restore). Recipes have no tab — they
|
||||
* land in [RecipeDetail] via the shell-wide search overlay.
|
||||
*/
|
||||
sealed interface Screen : NavKey {
|
||||
sealed interface Home : Screen {
|
||||
@Serializable
|
||||
data object Root : Home
|
||||
}
|
||||
|
||||
sealed interface Planner : Screen {
|
||||
@Serializable
|
||||
data object Home : Planner
|
||||
}
|
||||
|
||||
sealed interface Recipes : Screen {
|
||||
@Serializable
|
||||
data object Home : Recipes
|
||||
}
|
||||
|
||||
sealed interface Pantry : Screen {
|
||||
@Serializable
|
||||
data object Home : Pantry
|
||||
@@ -32,4 +28,12 @@ sealed interface Screen : NavKey {
|
||||
@Serializable
|
||||
data object Home : Shopping
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RecipeDetail(val recipeId: String) : Screen
|
||||
|
||||
sealed interface MealPlanEditor : Screen {
|
||||
@Serializable
|
||||
data class Open(val source: MealPlanEditorSource) : MealPlanEditor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,27 @@ package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
||||
@Stable
|
||||
class TabNavigator(
|
||||
initialTab: BottomBarDestination = BottomBarDestination.Default,
|
||||
initialTab: DockDestination = DockDestination.Default,
|
||||
) {
|
||||
private val backStacks: Map<BottomBarDestination, SnapshotStateList<Screen>> =
|
||||
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
||||
private val backStacks: Map<DockDestination, SnapshotStateList<Screen>> =
|
||||
DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
||||
|
||||
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
|
||||
var activeTab: DockDestination by mutableStateOf(initialTab)
|
||||
private set
|
||||
|
||||
val activeBackStack: SnapshotStateList<Screen>
|
||||
get() = backStacks.getValue(activeTab)
|
||||
|
||||
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
|
||||
backStacks.getValue(tab)
|
||||
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = backStacks.getValue(tab)
|
||||
|
||||
fun selectTab(tab: BottomBarDestination) {
|
||||
fun selectTab(tab: DockDestination) {
|
||||
if (tab == activeTab) {
|
||||
popToRoot(tab)
|
||||
} else {
|
||||
@@ -35,14 +34,14 @@ class TabNavigator(
|
||||
activeBackStack.add(screen)
|
||||
}
|
||||
|
||||
fun goBack(tab: BottomBarDestination = activeTab) {
|
||||
fun goBack(tab: DockDestination = activeTab) {
|
||||
val stack = backStacks.getValue(tab)
|
||||
if (stack.size > 1) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun popToRoot(tab: BottomBarDestination) {
|
||||
private fun popToRoot(tab: DockDestination) {
|
||||
val stack = backStacks.getValue(tab)
|
||||
while (stack.size > 1) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package dev.ulfrx.recipe.ui.components.button
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
@Composable
|
||||
fun CircleButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 48.dp,
|
||||
tint: Color = RecipeTheme.colors.surface,
|
||||
iconTint: Color = RecipeTheme.colors.content,
|
||||
iconSize: Dp = 24.dp,
|
||||
borderTint: Color = RecipeTheme.colors.borderCard,
|
||||
borderWidth: Dp = 1.dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 1.15f else 1f,
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
label = "CircleGlassButton scale",
|
||||
)
|
||||
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
modifier = modifier
|
||||
.scale(scale)
|
||||
.size(size),
|
||||
backgroundColor = tint,
|
||||
borderColor = borderTint,
|
||||
borderWidth = borderWidth,
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UnstyledIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(iconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,227 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.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.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
@Composable
|
||||
internal fun CalendarDayCell(
|
||||
date: LocalDate,
|
||||
state: DayState,
|
||||
isSelected: Boolean,
|
||||
isToday: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
numberStyle: TextStyle = RecipeTheme.typography.label.copy(fontWeight = FontWeight.Light),
|
||||
cellHeight: Dp = 36.dp,
|
||||
header: String? = null,
|
||||
headerStyle: TextStyle =
|
||||
RecipeTheme.typography.label.copy(
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 9.sp,
|
||||
lineHeight = 10.sp,
|
||||
),
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val baseColor = colors.content
|
||||
val mutedColor = colors.contentMuted
|
||||
val accent = colors.accent
|
||||
|
||||
val background = if (isSelected) accent.copy(alpha = 0.18f) else Color.Transparent
|
||||
val textColor =
|
||||
when {
|
||||
state.disabled -> mutedColor.copy(alpha = 0.45f)
|
||||
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
|
||||
isSelected -> accent
|
||||
else -> baseColor
|
||||
}
|
||||
val headerColor =
|
||||
if (isSelected || state.dimmed || state.disabled) textColor else mutedColor
|
||||
val ringColor =
|
||||
when {
|
||||
isSelected -> accent.copy(alpha = 0.55f)
|
||||
isToday -> baseColor.copy(alpha = 0.35f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = INDICATOR_MUTED_ALPHA)
|
||||
|
||||
val cellModifier = modifier.height(cellHeight).fillMaxWidth()
|
||||
val isClickable = LocalCalendarInteractive.current && !state.disabled
|
||||
|
||||
val content: @Composable () -> Unit = {
|
||||
DayCellInner(
|
||||
date = date,
|
||||
textColor = textColor,
|
||||
numberStyle = numberStyle,
|
||||
header = header,
|
||||
headerStyle = headerStyle,
|
||||
headerColor = headerColor,
|
||||
indicator = state.indicator,
|
||||
indicatorColor = indicatorColor,
|
||||
)
|
||||
}
|
||||
|
||||
if (isClickable) {
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
backgroundColor = background,
|
||||
contentColor = textColor,
|
||||
shape = CircleShape,
|
||||
borderColor = ringColor,
|
||||
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
|
||||
modifier = cellModifier,
|
||||
content = { content() },
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = cellModifier.dayCellSurface(background, ringColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = { content() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayCellInner(
|
||||
date: LocalDate,
|
||||
textColor: Color,
|
||||
numberStyle: TextStyle,
|
||||
header: String?,
|
||||
headerStyle: TextStyle,
|
||||
headerColor: Color,
|
||||
indicator: Boolean,
|
||||
indicatorColor: Color,
|
||||
) {
|
||||
if (header == null) {
|
||||
CenteredDayNumber(
|
||||
date = date,
|
||||
textColor = textColor,
|
||||
numberStyle = numberStyle,
|
||||
indicator = indicator,
|
||||
indicatorColor = indicatorColor,
|
||||
)
|
||||
} else {
|
||||
HeaderDayNumber(
|
||||
date = date,
|
||||
textColor = textColor,
|
||||
numberStyle = numberStyle,
|
||||
header = header,
|
||||
headerStyle = headerStyle,
|
||||
headerColor = headerColor,
|
||||
indicator = indicator,
|
||||
indicatorColor = indicatorColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredDayNumber(
|
||||
date: LocalDate,
|
||||
textColor: Color,
|
||||
numberStyle: TextStyle,
|
||||
indicator: Boolean,
|
||||
indicatorColor: Color,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
BasicText(
|
||||
text = date.dayOfMonth.toString(),
|
||||
style = numberStyle.copy(color = textColor),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
if (indicator) {
|
||||
IndicatorDot(
|
||||
color = indicatorColor,
|
||||
modifier = Modifier.align(Alignment.Center).offset(y = 11.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderDayNumber(
|
||||
date: LocalDate,
|
||||
textColor: Color,
|
||||
numberStyle: TextStyle,
|
||||
header: String,
|
||||
headerStyle: TextStyle,
|
||||
headerColor: Color,
|
||||
indicator: Boolean,
|
||||
indicatorColor: Color,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BasicText(text = header, style = headerStyle.copy(color = headerColor))
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
BasicText(
|
||||
text = date.dayOfMonth.toString(),
|
||||
style = numberStyle.copy(color = textColor),
|
||||
)
|
||||
}
|
||||
if (indicator) {
|
||||
IndicatorDot(
|
||||
color = indicatorColor,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IndicatorDot(
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Modifier.dayCellSurface(
|
||||
backgroundColor: Color,
|
||||
ringColor: Color,
|
||||
): Modifier =
|
||||
this
|
||||
.background(backgroundColor, CircleShape)
|
||||
.then(
|
||||
if (ringColor == Color.Transparent) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.border(1.dp, ringColor, CircleShape)
|
||||
},
|
||||
)
|
||||
|
||||
private const val INDICATOR_MUTED_ALPHA = 0.6f
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.calendar_horizon_days
|
||||
import recipe.composeapp.generated.resources.calendar_horizon_today
|
||||
|
||||
@Composable
|
||||
fun horizonLabel(
|
||||
today: LocalDate,
|
||||
end: LocalDate,
|
||||
): String {
|
||||
val days = (today.daysUntil(end) + 1).coerceAtLeast(1)
|
||||
return if (days == 1) {
|
||||
stringResource(Res.string.calendar_horizon_today)
|
||||
} else {
|
||||
stringResource(Res.string.calendar_horizon_days, days)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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.text.font.FontWeight
|
||||
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,
|
||||
fontWeight = FontWeight.Light,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,350 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
enum class CalendarPillExpandDirection {
|
||||
/** Pill anchored at the bottom; calendar slides into view from above (planner pattern). */
|
||||
Up,
|
||||
|
||||
/** Pill anchored at the top; calendar grows downward beneath it (in-sheet editor pattern). */
|
||||
Down,
|
||||
;
|
||||
|
||||
/** Sign convention: positive drag/velocity along this axis opens the pill. */
|
||||
val openingSign: Float
|
||||
get() =
|
||||
when (this) {
|
||||
Up -> -1f
|
||||
Down -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarPill(
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
selectedDate: LocalDate,
|
||||
today: LocalDate,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "",
|
||||
collapsedContent: (@Composable RowScope.() -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
dayState: (LocalDate) -> DayState = { DayState() },
|
||||
pillHeight: Dp = 48.dp,
|
||||
locale: CalendarLocale = CalendarLocale.PL,
|
||||
expandDirection: CalendarPillExpandDirection = CalendarPillExpandDirection.Up,
|
||||
tint: Color = RecipeTheme.colors.surfaceGlass,
|
||||
glass: Boolean = true,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val expansion = remember { PillExpansion(initial = if (expanded) 1f else 0f) }
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
expansion.animateTo(scope, target = if (expanded) 1f else 0f)
|
||||
}
|
||||
|
||||
val progress = expansion.progress
|
||||
val cornerRadius = pillHeight / 2 * (1f - progress) + EXPANDED_CORNER_RADIUS * progress
|
||||
val pillInset = RecipeTheme.spacing.lg + RecipeTheme.spacing.xs
|
||||
val pillHeightPx = with(LocalDensity.current) { pillHeight.toPx() }
|
||||
val dragState =
|
||||
rememberDraggableState { delta ->
|
||||
expansion.dragBy(
|
||||
delta = delta,
|
||||
range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f),
|
||||
direction = expandDirection,
|
||||
)
|
||||
}
|
||||
|
||||
PillSurface(
|
||||
glass = glass,
|
||||
tint = tint,
|
||||
cornerRadius = cornerRadius,
|
||||
glassStyle = if (expanded) RecipeTheme.glass.panel else RecipeTheme.glass.dock,
|
||||
modifier =
|
||||
modifier.draggable(
|
||||
state = dragState,
|
||||
orientation = Orientation.Vertical,
|
||||
onDragStarted = { expansion.cancelSettle() },
|
||||
onDragStopped = { velocity ->
|
||||
val openTarget = releaseTarget(expansion.progress, velocity, expandDirection)
|
||||
val range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f)
|
||||
val initialVelocity = expandDirection.openingSign * velocity / range
|
||||
expansion.animateTo(scope, if (openTarget) 1f else 0f, initialVelocity = initialVelocity)
|
||||
if (openTarget != expanded) onExpandedChange(openTarget)
|
||||
},
|
||||
),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
CompositionLocalProvider(LocalCalendarInteractive provides expanded) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.expandingHeight(progress, pillHeight, expansion, expandDirection)
|
||||
.alpha(progress),
|
||||
) {
|
||||
SwipeableCalendar(
|
||||
selectedDate = selectedDate,
|
||||
today = today,
|
||||
mode = CalendarMode.Month,
|
||||
onSelectDate = onSelectDate,
|
||||
onModeChange = {},
|
||||
onVisibleAnchorChange = {},
|
||||
dayState = dayState,
|
||||
expandable = false,
|
||||
locale = locale,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = RecipeTheme.spacing.lg),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val rowAlpha = (1f - progress / PILL_CONTENT_FADE_END).coerceIn(0f, 1f)
|
||||
if (rowAlpha > 0f) {
|
||||
val pillRowAlignment =
|
||||
when (expandDirection) {
|
||||
CalendarPillExpandDirection.Up -> Alignment.BottomCenter
|
||||
CalendarPillExpandDirection.Down -> Alignment.TopCenter
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(pillRowAlignment)
|
||||
.alpha(rowAlpha),
|
||||
) {
|
||||
PillRow(
|
||||
label = label,
|
||||
collapsedContent = collapsedContent,
|
||||
trailing = trailing,
|
||||
height = pillHeight,
|
||||
horizontalInset = pillInset,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface wrapper for the pill. Glass mode is the default and matches the
|
||||
* planner pattern where the pill sits over a varied app-shell backdrop and
|
||||
* refraction earns its keep. The flat mode is for in-sheet contexts where the
|
||||
* backdrop is mostly a solid colour — refraction has nothing meaningful to
|
||||
* refract and only adds visual noise.
|
||||
*/
|
||||
@Composable
|
||||
private fun PillSurface(
|
||||
glass: Boolean,
|
||||
tint: Color,
|
||||
cornerRadius: Dp,
|
||||
glassStyle: RecipeGlassStyle,
|
||||
modifier: Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
if (glass) {
|
||||
GlassSurface(
|
||||
modifier = modifier,
|
||||
cornerRadius = cornerRadius,
|
||||
glassStyle = glassStyle,
|
||||
content = content,
|
||||
)
|
||||
} else {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(cornerRadius)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(shape)
|
||||
.background(tint)
|
||||
.border(width = FlatBorderWidth, color = colors.borderCard, shape = shape),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PillRow(
|
||||
label: String,
|
||||
collapsedContent: (@Composable RowScope.() -> Unit)?,
|
||||
trailing: (@Composable () -> Unit)?,
|
||||
height: Dp,
|
||||
horizontalInset: Dp,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.padding(horizontal = horizontalInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (collapsedContent != null) {
|
||||
collapsedContent()
|
||||
} else {
|
||||
BasicText(
|
||||
text = label,
|
||||
style = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the calendar at its full intrinsic height, reports it to [expansion]
|
||||
* so drag knows the range, then lays out at the lerped height. The placement
|
||||
* anchor flips with [direction]: anchoring the calendar's bottom edge makes it
|
||||
* slide in from above (pill at bottom); anchoring the top edge makes the
|
||||
* calendar reveal downward (pill at top).
|
||||
*/
|
||||
private fun Modifier.expandingHeight(
|
||||
progress: Float,
|
||||
pillHeight: Dp,
|
||||
expansion: PillExpansion,
|
||||
direction: CalendarPillExpandDirection,
|
||||
): Modifier =
|
||||
this.layout { measurable, constraints ->
|
||||
val placeable =
|
||||
measurable.measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
|
||||
expansion.reportFullHeight(placeable.height)
|
||||
val pillHeightPx = pillHeight.roundToPx()
|
||||
val height = lerp(pillHeightPx, placeable.height, progress).coerceIn(pillHeightPx, placeable.height)
|
||||
layout(placeable.width, height) {
|
||||
val placementY =
|
||||
when (direction) {
|
||||
CalendarPillExpandDirection.Up -> height - placeable.height
|
||||
CalendarPillExpandDirection.Down -> 0
|
||||
}
|
||||
placeable.place(0, placementY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for pill drag/settle state. Holds [progress] (0 =
|
||||
* collapsed, 1 = expanded) and tracks [target] so external [expanded] changes
|
||||
* that match an in-flight settle become no-ops — no flag, no race.
|
||||
*/
|
||||
@Stable
|
||||
private class PillExpansion(
|
||||
initial: Float,
|
||||
) {
|
||||
var progress by mutableFloatStateOf(initial)
|
||||
private set
|
||||
var fullHeightPx by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
private var target: Float = initial
|
||||
private var settleJob: Job? = null
|
||||
|
||||
fun dragBy(
|
||||
delta: Float,
|
||||
range: Float,
|
||||
direction: CalendarPillExpandDirection,
|
||||
) {
|
||||
settleJob?.cancel()
|
||||
progress = (progress + direction.openingSign * delta / range).coerceIn(0f, 1f)
|
||||
target = progress
|
||||
}
|
||||
|
||||
fun animateTo(
|
||||
scope: CoroutineScope,
|
||||
target: Float,
|
||||
initialVelocity: Float = 0f,
|
||||
) {
|
||||
if (this.target == target && settleJob?.isActive == true) return
|
||||
this.target = target
|
||||
settleJob?.cancel()
|
||||
settleJob =
|
||||
scope.launch {
|
||||
Animatable(progress)
|
||||
.also { it.updateBounds(0f, 1f) }
|
||||
.animateTo(
|
||||
targetValue = target,
|
||||
animationSpec =
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
initialVelocity = initialVelocity,
|
||||
) { progress = value }
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSettle() {
|
||||
settleJob?.cancel()
|
||||
}
|
||||
|
||||
fun reportFullHeight(height: Int) {
|
||||
if (fullHeightPx != height) fullHeightPx = height
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseTarget(
|
||||
progress: Float,
|
||||
velocity: Float,
|
||||
direction: CalendarPillExpandDirection,
|
||||
): Boolean {
|
||||
val openingVelocity = direction.openingSign * velocity
|
||||
return when {
|
||||
openingVelocity >= FLING_VELOCITY -> true
|
||||
openingVelocity <= -FLING_VELOCITY -> false
|
||||
else -> progress >= 0.5f
|
||||
}
|
||||
}
|
||||
|
||||
private const val FLING_VELOCITY = 60f
|
||||
private const val PILL_CONTENT_FADE_END = 0.35f
|
||||
private val EXPANDED_CORNER_RADIUS = 28.dp
|
||||
private val FlatBorderWidth = 1.dp
|
||||
@@ -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,80 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
|
||||
/**
|
||||
* Day-cell interactivity gate. CalendarPill flips this to `false` while
|
||||
* collapsed so the always-composed month grid (kept in the tree to feed drag
|
||||
* its full height) doesn't catch taps that visually belong to the pill row.
|
||||
*/
|
||||
internal val LocalCalendarInteractive = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* 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,51 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Mon-anchored 7-day strip rendering [CalendarDayCell] per day. Used by every
|
||||
* surface that embeds [CalendarPill] in its collapsed form (planner, meal-plan
|
||||
* editor, future pantry/shopping pills).
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarWeekStrip(
|
||||
selectedDate: LocalDate,
|
||||
today: LocalDate,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
numberStyle: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
dayState: (LocalDate) -> DayState = { DayState() },
|
||||
locale: CalendarLocale = CalendarLocale.PL,
|
||||
) {
|
||||
val days = weekStripDays(selectedDate)
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(DayCellGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
days.forEachIndexed { index, day ->
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
CalendarDayCell(
|
||||
date = day,
|
||||
state = dayState(day),
|
||||
isSelected = day == selectedDate,
|
||||
isToday = day == today,
|
||||
onClick = { onSelectDate(day) },
|
||||
numberStyle = numberStyle,
|
||||
header = locale.weekdaysShort[index],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DayCellGap = 4.dp
|
||||
@@ -0,0 +1,128 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
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.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.datetime.DatePeriod
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* Paged version of [CalendarWeekStrip] — horizontally swipeable. Each page
|
||||
* renders one week's days; swiping fires [onSelectionShift] with the same
|
||||
* weekday in the now-visible week so the caller can move the highlighted day
|
||||
* along with the navigation. Tapping a day still goes through [onSelectDate].
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarWeekStripPager(
|
||||
selectedDate: LocalDate,
|
||||
today: LocalDate,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
onSelectionShift: (LocalDate) -> Unit,
|
||||
numberStyle: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
dayState: (LocalDate) -> DayState = { DayState() },
|
||||
locale: CalendarLocale = CalendarLocale.PL,
|
||||
) {
|
||||
val origin = remember { selectedDate }
|
||||
val initialPage = remember { PAGE_COUNT / 2 }
|
||||
val pagerState = rememberPagerState(initialPage = initialPage) { PAGE_COUNT }
|
||||
val currentOnSelectionShift by rememberUpdatedState(onSelectionShift)
|
||||
|
||||
// Bring the pager onto the page that contains [selectedDate] whenever it
|
||||
// changes from outside the pager — e.g., the user picked a day from the
|
||||
// expanded month grid before collapsing.
|
||||
LaunchedEffect(selectedDate) {
|
||||
val target = initialPage + periodsBetween(origin, selectedDate, CalendarMode.Week)
|
||||
if (target != pagerState.currentPage) {
|
||||
pagerState.animateScrollToPage(target)
|
||||
}
|
||||
}
|
||||
|
||||
// Report swipe-driven page changes upward as "shift selection to the same
|
||||
// weekday in the now-visible week" so the highlight follows the navigation.
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }
|
||||
.distinctUntilChanged()
|
||||
.collect { page ->
|
||||
if (page == initialPage) return@collect
|
||||
val visibleWeekAnchor = origin.plusPeriods(page - initialPage, CalendarMode.Week)
|
||||
if (!isInVisiblePeriod(selectedDate, visibleWeekAnchor, CalendarMode.Week)) {
|
||||
val deltaWeeks = page - initialPage
|
||||
currentOnSelectionShift(selectedDate.plus(DatePeriod(days = deltaWeeks * DAYS_PER_WEEK)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
pageSpacing = 0.dp,
|
||||
) { page ->
|
||||
val pageAnchor = origin.plusPeriods(page - initialPage, CalendarMode.Week)
|
||||
WeekStripWithHeaders(
|
||||
anchor = pageAnchor,
|
||||
selectedDate = selectedDate,
|
||||
today = today,
|
||||
onSelectDate = onSelectDate,
|
||||
numberStyle = numberStyle,
|
||||
dayState = dayState,
|
||||
locale = locale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekStripWithHeaders(
|
||||
anchor: LocalDate,
|
||||
selectedDate: LocalDate,
|
||||
today: LocalDate,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
numberStyle: TextStyle,
|
||||
dayState: (LocalDate) -> DayState,
|
||||
locale: CalendarLocale,
|
||||
) {
|
||||
val days = weekStripDays(anchor)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(DayCellGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
days.forEachIndexed { index, day ->
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
CalendarDayCell(
|
||||
date = day,
|
||||
state = dayState(day),
|
||||
isSelected = day == selectedDate,
|
||||
isToday = day == today,
|
||||
onClick = { onSelectDate(day) },
|
||||
numberStyle = numberStyle,
|
||||
header = locale.weekdaysShort[index],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
// Centered start lets the pager scroll forward and backward freely — mirrors
|
||||
// the convention used by [SwipeableCalendar]; 100k pages in either direction is
|
||||
// ~1900 years so users will never run off the edge.
|
||||
private const val PAGE_COUNT: Int = 200_000
|
||||
|
||||
private val DayCellGap = 4.dp
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.datetime.DatePeriod
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
@Immutable
|
||||
data class HorizonCalendarState(
|
||||
val selectedDate: LocalDate,
|
||||
val isCalendarOpen: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared state holder for "pick a horizon date" screens (Pantry, Shopping).
|
||||
* Owns the date + open flag and enforces "no past dates" on selection. Lives
|
||||
* inside the owning ViewModel as a plain field — not a ViewModel itself.
|
||||
*
|
||||
* [today] is parameterised so tests can pin the clock.
|
||||
*/
|
||||
class HorizonCalendarHolder(
|
||||
initialDate: LocalDate = defaultHorizon(),
|
||||
private val today: () -> LocalDate = ::todayInSystemTz,
|
||||
) {
|
||||
private val _state = MutableStateFlow(HorizonCalendarState(selectedDate = initialDate))
|
||||
val state: StateFlow<HorizonCalendarState> = _state.asStateFlow()
|
||||
|
||||
fun setOpen(open: Boolean) {
|
||||
_state.update { it.copy(isCalendarOpen = open) }
|
||||
}
|
||||
|
||||
fun close() = setOpen(false)
|
||||
|
||||
fun select(date: LocalDate) {
|
||||
if (date < today()) return
|
||||
_state.update { it.copy(selectedDate = date, isCalendarOpen = false) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_HORIZON_DAYS = 7
|
||||
|
||||
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate = today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
@Composable
|
||||
fun HorizonCalendarPill(
|
||||
selectedDate: LocalDate,
|
||||
expanded: Boolean,
|
||||
today: LocalDate,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable () -> Unit,
|
||||
) {
|
||||
CalendarPill(
|
||||
label = horizonLabel(today, selectedDate),
|
||||
expanded = expanded,
|
||||
onExpandedChange = onExpandedChange,
|
||||
selectedDate = selectedDate,
|
||||
today = today,
|
||||
onSelectDate = onSelectDate,
|
||||
trailing = trailing,
|
||||
dayState = { date ->
|
||||
if (date < today) DayState(disabled = true, dimmed = true) else DayState()
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dev.ulfrx.recipe.ui.components.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Project-default wrapping of [CalendarPill] — collapsed state shows a paged
|
||||
* week strip plus the current month's short name. Used by the planner pill,
|
||||
* the meal-plan editor's in-sheet calendar, and any other surface that wants
|
||||
* the "swipe weeks, drag to expand to a month grid" pattern.
|
||||
*
|
||||
* Callers tweak [expandDirection] / [glass] / [tint] / [plannedDates] to match
|
||||
* their host context but the layout, typography and gesture handling stay
|
||||
* unified across screens.
|
||||
*/
|
||||
@Composable
|
||||
fun RecipeCalendarPill(
|
||||
selectedDate: LocalDate,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
onSelectionShift: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
plannedDates: Set<LocalDate> = emptySet(),
|
||||
expandDirection: CalendarPillExpandDirection = CalendarPillExpandDirection.Up,
|
||||
glass: Boolean = true,
|
||||
tint: Color = RecipeTheme.colors.surfaceGlass,
|
||||
locale: CalendarLocale = CalendarLocale.PL,
|
||||
) {
|
||||
val today = remember { todayInSystemTz() }
|
||||
val dayState =
|
||||
remember(plannedDates) {
|
||||
{ date: LocalDate -> DayState(indicator = date in plannedDates) }
|
||||
}
|
||||
val pillTextStyle =
|
||||
RecipeTheme.typography.label.copy(
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = PillTextSize,
|
||||
)
|
||||
|
||||
val handleDayPick: (LocalDate) -> Unit = { date ->
|
||||
onSelectDate(date)
|
||||
if (expanded) onExpandedChange(false)
|
||||
}
|
||||
|
||||
CalendarPill(
|
||||
expanded = expanded,
|
||||
onExpandedChange = onExpandedChange,
|
||||
selectedDate = selectedDate,
|
||||
today = today,
|
||||
onSelectDate = handleDayPick,
|
||||
expandDirection = expandDirection,
|
||||
glass = glass,
|
||||
tint = tint,
|
||||
collapsedContent = {
|
||||
CalendarWeekStripPager(
|
||||
selectedDate = selectedDate,
|
||||
today = today,
|
||||
onSelectDate = handleDayPick,
|
||||
onSelectionShift = onSelectionShift,
|
||||
numberStyle = pillTextStyle,
|
||||
dayState = dayState,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
BasicText(
|
||||
text = locale.monthsShort[selectedDate.monthNumber - 1],
|
||||
style = pillTextStyle.copy(color = RecipeTheme.colors.contentMuted),
|
||||
)
|
||||
},
|
||||
dayState = dayState,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
private val PillTextSize = 12.sp
|
||||
@@ -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
|
||||
@@ -0,0 +1,82 @@
|
||||
package dev.ulfrx.recipe.ui.components.chips
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Selectable chip for meal-plan slots (śniadanie / lunch / obiad / kolacja /
|
||||
* przekąska). Flat surface — no glass refraction — because the chip row sits
|
||||
* on the editor's static background where liquid effects add visual noise
|
||||
* without revealing anything underneath. Disabled state renders for slots not
|
||||
* in the recipe's `allowedSlots`.
|
||||
*/
|
||||
@Composable
|
||||
fun MealSlotChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(ChipCornerRadius)
|
||||
val backgroundColor =
|
||||
when {
|
||||
!enabled -> Color.Transparent
|
||||
selected -> colors.accent.copy(alpha = SelectedBackgroundAlpha)
|
||||
else -> colors.surface
|
||||
}
|
||||
val borderColor =
|
||||
when {
|
||||
!enabled -> Color.Transparent
|
||||
selected -> colors.accent.copy(alpha = SelectedBorderAlpha)
|
||||
else -> colors.borderCard
|
||||
}
|
||||
val labelColor =
|
||||
when {
|
||||
!enabled -> colors.contentMuted.copy(alpha = DisabledLabelAlpha)
|
||||
selected -> colors.accent
|
||||
else -> colors.content
|
||||
}
|
||||
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
backgroundColor = backgroundColor,
|
||||
contentColor = labelColor,
|
||||
shape = shape,
|
||||
borderColor = borderColor,
|
||||
borderWidth = if (borderColor == Color.Transparent) 0.dp else BorderWidth,
|
||||
contentPadding = PaddingValues(horizontal = HorizontalPadding, vertical = VerticalPadding),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BasicText(
|
||||
text = label,
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = labelColor,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = LabelTextSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val SelectedBackgroundAlpha = 0.18f
|
||||
private const val SelectedBorderAlpha = 0.55f
|
||||
private const val DisabledLabelAlpha = 0.45f
|
||||
|
||||
private val ChipCornerRadius = 14.dp
|
||||
private val BorderWidth = 1.dp
|
||||
private val HorizontalPadding = 10.dp
|
||||
private val VerticalPadding = 7.dp
|
||||
private val LabelTextSize = 11.sp
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -22,24 +21,9 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Circular Liquid-glass icon button with iOS-style press feedback.
|
||||
*
|
||||
* Visual behaviour on press:
|
||||
* - Scale 1.0 → 1.15 (whole button briefly grows under the finger).
|
||||
* - Substrate tint shifts from [RecipeTheme.colors.surfaceGlass] to a
|
||||
* translucent white overlay, so the button reads "lit up".
|
||||
*
|
||||
* Both transitions use the same 120 ms tween with [FastOutSlowInEasing], so
|
||||
* the scale and tint move together. Compose's default [androidx.compose.foundation.Indication]
|
||||
* (ripple / state-layer) is suppressed (`indication = null`) — this scale +
|
||||
* tint pair is the project's standard press affordance for circular chrome.
|
||||
*
|
||||
* Used by the dock's floating search button, the search overlay's dismiss
|
||||
* button, and any future round glass action in the chrome family.
|
||||
*/
|
||||
@Composable
|
||||
fun CircleGlassButton(
|
||||
onClick: () -> Unit,
|
||||
@@ -49,21 +33,16 @@ fun CircleGlassButton(
|
||||
size: Dp = 48.dp,
|
||||
iconSize: Dp = 24.dp,
|
||||
iconTint: Color = RecipeTheme.colors.content,
|
||||
glassStyle: RecipeGlassStyle = RecipeTheme.glass.dock,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
val pressedTint = Color.White.copy(alpha = 0.18f)
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 1.15f else 1f,
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
label = "CircleGlassButton scale",
|
||||
)
|
||||
val tint by animateColorAsState(
|
||||
targetValue = if (isPressed) pressedTint else RecipeTheme.colors.surfaceGlass,
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
label = "CircleGlassButton tint",
|
||||
)
|
||||
|
||||
GlassSurface(
|
||||
modifier =
|
||||
@@ -71,7 +50,7 @@ fun CircleGlassButton(
|
||||
.scale(scale)
|
||||
.size(size),
|
||||
cornerRadius = size / 2,
|
||||
tint = tint,
|
||||
glassStyle = glassStyle,
|
||||
) {
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
|
||||
@@ -3,48 +3,41 @@ package dev.ulfrx.recipe.ui.components.glass
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.github.fletchmckee.liquid.LiquidState
|
||||
import io.github.fletchmckee.liquid.liquefiable
|
||||
import io.github.fletchmckee.liquid.rememberLiquidState
|
||||
|
||||
val LocalGlassBackdropState =
|
||||
staticCompositionLocalOf<GlassBackdropState> {
|
||||
error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared source/sampling state for glass chrome.
|
||||
*
|
||||
* AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
|
||||
* consume [LocalGlassBackdropState] so Liquid sample the same layer behind
|
||||
* the dock/search chrome.
|
||||
*/
|
||||
@Stable
|
||||
class GlassBackdropState internal constructor(
|
||||
internal val liquidState: Any,
|
||||
internal val liquidState: LiquidState,
|
||||
)
|
||||
|
||||
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
||||
|
||||
@Composable
|
||||
fun rememberGlassBackdropState(): GlassBackdropState {
|
||||
val liquidState = rememberLiquidBackdropHandle()
|
||||
val liquidState = rememberLiquidState()
|
||||
return remember(liquidState) {
|
||||
GlassBackdropState(
|
||||
liquidState = liquidState,
|
||||
)
|
||||
GlassBackdropState(liquidState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlassBackdropSource(
|
||||
state: GlassBackdropState,
|
||||
modifier: Modifier = Modifier,
|
||||
state: GlassBackdropState = rememberGlassBackdropState(),
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides state) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.liquidBackdropSource(state),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.liquefiable(state.liquidState),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import io.github.fletchmckee.liquid.liquefiable
|
||||
import io.github.fletchmckee.liquid.liquid
|
||||
|
||||
/**
|
||||
* @param recordAsSource Also register this surface as a Liquid source so other
|
||||
* [GlassSurface]s sampling the same backdrop see this surface's refracted
|
||||
* output — needed for nested glass-on-glass (e.g. a press overlay over the
|
||||
* dock substrate). Liquid's ancestor-exclusion prevents this surface from
|
||||
* sampling itself; outside its bounds it contributes nothing, so siblings
|
||||
* that extend past the source's edges fall back to the shell backdrop
|
||||
* seamlessly.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = RecipeTheme.colors.surfaceGlass,
|
||||
cornerRadius: Dp = 28.dp,
|
||||
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
||||
edgeIntensity: Float = 0.05f,
|
||||
glassStyle: RecipeGlassStyle = RecipeTheme.glass.dock,
|
||||
recordAsSource: Boolean = false,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val backdropState = LocalGlassBackdropState.current
|
||||
LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, edgeIntensity, content)
|
||||
val shape = RoundedCornerShape(cornerRadius)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(shape)
|
||||
.then(if (recordAsSource) Modifier.liquefiable(backdropState.liquidState) else Modifier)
|
||||
.liquid(backdropState.liquidState) {
|
||||
refraction = glassStyle.refraction
|
||||
curve = glassStyle.curve
|
||||
edge = glassStyle.edge
|
||||
dispersion = glassStyle.dispersion
|
||||
saturation = glassStyle.saturation
|
||||
contrast = glassStyle.contrast
|
||||
frost = glassStyle.frost
|
||||
this.shape = shape
|
||||
glassStyle.tint?.let { this.tint = it }
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,33 +25,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Pill-shaped Liquid-glass text input with iOS-style press feedback.
|
||||
*
|
||||
* Visual behaviour on press:
|
||||
* - Scale 1.0 → 1.05 (subtle — the pill is wide, so even small percentages
|
||||
* are readable). Tween 120 ms `FastOutSlowInEasing`, matching the project's
|
||||
* standard chrome-interaction timing.
|
||||
* - **No** tint change — the keyboard appearing is its own colour event, so
|
||||
* additional brightness on the field would compete.
|
||||
*
|
||||
* Press detection uses `Modifier.pointerInput` with `awaitEachGesture`, but
|
||||
* never *consumes* the down event — the wrapped [BasicTextField] still
|
||||
* receives the tap and handles focus / IME naturally. The scale animation
|
||||
* runs concurrently with the focus request, so the user sees the pill bounce
|
||||
* up the moment they touch it, while the keyboard slides into place.
|
||||
*
|
||||
* Reusable for any glass-style text input. [leadingContent] is a `null`-able
|
||||
* slot for a leading icon or other affordance; if null, the field starts at
|
||||
* the pill's leading edge.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassTextField(
|
||||
value: String,
|
||||
@@ -122,10 +104,7 @@ fun GlassTextField(
|
||||
if (value.isEmpty()) {
|
||||
BasicText(
|
||||
text = placeholder,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = Color.White,
|
||||
),
|
||||
style = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
|
||||
)
|
||||
}
|
||||
innerField()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,86 @@
|
||||
package dev.ulfrx.recipe.ui.components.overlay
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Scaffold for a bottom-anchored modal overlay (calendar pill today; future
|
||||
* bottom-sheets, filter panels). Owns four crosscuts so screens don't repeat
|
||||
* them:
|
||||
* - **Local glass backdrop** — Liquid refraction filters the nearest
|
||||
* liquefiable ancestor, so the overlay must be a sibling of its own
|
||||
* backdrop source (not a descendant of the shell's global one).
|
||||
* - **Scrim** — tap-outside dismisses while [open] is true.
|
||||
* - **Tab/route exit** — closes the overlay on dispose to keep state honest
|
||||
* when the user navigates away mid-open.
|
||||
* - **Active-tab tap** — registers with [OverlayDismisser] so a tap on the
|
||||
* already-active tab in the shell closes us too.
|
||||
*/
|
||||
@Composable
|
||||
fun BottomOverlayScaffold(
|
||||
open: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
bottomInset: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
overlay: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val backdrop = rememberGlassBackdropState()
|
||||
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
||||
val latestOpen by rememberUpdatedState(open)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { if (latestOpen) latestOnDismiss() }
|
||||
}
|
||||
|
||||
RegisterDismissibleOverlay(active = open, onDismiss = onDismiss)
|
||||
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { onDismiss() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = RecipeTheme.spacing.xl)
|
||||
.padding(bottom = bottomInset),
|
||||
) {
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.ulfrx.recipe.ui.components.overlay
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
@Stable
|
||||
class OverlayDismisser {
|
||||
private val handlers = mutableListOf<() -> Unit>()
|
||||
|
||||
fun register(onDismiss: () -> Unit): () -> Unit {
|
||||
handlers += onDismiss
|
||||
return { handlers -= onDismiss }
|
||||
}
|
||||
|
||||
fun dismissAll() {
|
||||
handlers.toList().forEach { it() }
|
||||
}
|
||||
}
|
||||
|
||||
val LocalOverlayDismisser =
|
||||
staticCompositionLocalOf<OverlayDismisser> {
|
||||
error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegisterDismissibleOverlay(
|
||||
active: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dismisser = LocalOverlayDismisser.current
|
||||
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
||||
DisposableEffect(dismisser, active) {
|
||||
val unregister = if (active) dismisser.register { latestOnDismiss() } else null
|
||||
onDispose { unregister?.invoke() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlin.math.round
|
||||
|
||||
/**
|
||||
* Right-aligned amount + unit pair shared by [IngredientRow] (recipe detail
|
||||
* and meal-plan editor) and the addable-catalog rows in the "Dodaj składnik"
|
||||
* search panel. Amount is locale-formatted with a comma decimal; unit is
|
||||
* rendered muted so the value reads as primary.
|
||||
*/
|
||||
@Composable
|
||||
fun IngredientAmount(
|
||||
amount: Double,
|
||||
unit: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val typography = RecipeTheme.typography
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
|
||||
) {
|
||||
BasicText(
|
||||
text = formatIngredientAmount(amount),
|
||||
style =
|
||||
typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = AmountTextSize,
|
||||
lineHeight = AmountLineHeight,
|
||||
),
|
||||
)
|
||||
BasicText(
|
||||
text = unit,
|
||||
style =
|
||||
typography.body.copy(
|
||||
color = colors.contentMuted,
|
||||
fontSize = UnitTextSize,
|
||||
lineHeight = AmountLineHeight,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One-decimal-place comma-formatted amount: 200.0 → "200", 1.5 → "1,5". */
|
||||
internal fun formatIngredientAmount(value: Double): String {
|
||||
val scaled = round(value * 10.0).toLong()
|
||||
val whole = scaled / 10
|
||||
val frac = (scaled % 10).toInt()
|
||||
return if (frac == 0) whole.toString() else "$whole,$frac"
|
||||
}
|
||||
|
||||
private val AmountTextSize = 12.sp
|
||||
private val UnitTextSize = 11.sp
|
||||
private val AmountLineHeight = 16.sp
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Wrapping card used by both the read-only recipe detail and the meal-plan
|
||||
* editor to host a list of [IngredientRow]s separated by [IngredientDivider].
|
||||
* Surface, border and corner radius are unified so the two screens read as the
|
||||
* same widget rendered against different sources of truth.
|
||||
*/
|
||||
@Composable
|
||||
fun IngredientCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(CardCornerRadius)
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(colors.surface)
|
||||
.border(width = CardBorderWidth, color = colors.borderCard, shape = shape),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private val CardCornerRadius = 16.dp
|
||||
private val CardBorderWidth = 1.dp
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Thin separator drawn between consecutive [IngredientRow]s inside the
|
||||
* shared wrapping ingredient card. Inset matches the row's horizontal
|
||||
* padding so the line never reaches the card's rounded edges.
|
||||
*/
|
||||
@Composable
|
||||
fun IngredientDivider(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DividerHorizontalInset)
|
||||
.height(DividerThickness)
|
||||
.background(RecipeTheme.colors.separator),
|
||||
)
|
||||
}
|
||||
|
||||
private val DividerHorizontalInset = 12.dp
|
||||
private val DividerThickness = 1.dp
|
||||
@@ -0,0 +1,293 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.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.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composables.icons.lucide.Check
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Plus
|
||||
import com.composables.icons.lucide.Shuffle
|
||||
import com.composables.icons.lucide.X
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.ingredient_substitute_a11y
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_added_marker_a11y
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_remove_ingredient_a11y
|
||||
|
||||
data class RecipeIngredientOptionUi(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val amount: Double,
|
||||
val unit: String,
|
||||
)
|
||||
|
||||
data class RecipeIngredientSlotUi(
|
||||
val default: RecipeIngredientOptionUi,
|
||||
val alternatives: List<RecipeIngredientOptionUi> = emptyList(),
|
||||
val id: String = default.id,
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared row used in both the read-only recipe detail and the meal-plan
|
||||
* editor. Detail uses the base form (name + optional swap + amount); editor
|
||||
* passes [onRemove] / [addedMarker] to surface its extra affordances inside
|
||||
* the same visual language.
|
||||
*/
|
||||
@Composable
|
||||
fun IngredientRow(
|
||||
slot: RecipeIngredientSlotUi,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedOptionId: String = slot.default.id,
|
||||
onSelect: ((RecipeIngredientOptionUi) -> Unit)? = null,
|
||||
addedMarker: Boolean = false,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
val options = slot.options
|
||||
val selected = options.firstOrNull { it.id == selectedOptionId } ?: slot.default
|
||||
val swappable = slot.alternatives.isNotEmpty() && onSelect != null
|
||||
var expanded by remember(slot.id) { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = MinRowHeight)
|
||||
.padding(horizontal = PaddingHorizontal, vertical = PaddingVertical),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
NameLine(
|
||||
name = selected.name,
|
||||
addedMarker = addedMarker,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (swappable) {
|
||||
IconBadgeButton(
|
||||
icon = Lucide.Shuffle,
|
||||
contentDescription = stringResource(Res.string.ingredient_substitute_a11y),
|
||||
onClick = { expanded = !expanded },
|
||||
)
|
||||
}
|
||||
IngredientAmount(amount = selected.amount, unit = selected.unit)
|
||||
if (onRemove != null) {
|
||||
IconBadgeButton(
|
||||
icon = Lucide.X,
|
||||
contentDescription = stringResource(Res.string.meal_plan_editor_remove_ingredient_a11y),
|
||||
onClick = onRemove,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (swappable && expanded) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = PaddingHorizontal, end = PaddingHorizontal, bottom = PaddingVertical),
|
||||
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
AlternativeOption(
|
||||
option = option,
|
||||
selected = option.id == selected.id,
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameLine(
|
||||
name: String,
|
||||
addedMarker: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
|
||||
) {
|
||||
BasicText(
|
||||
text = name,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = NameTextSize,
|
||||
lineHeight = LineHeight,
|
||||
),
|
||||
)
|
||||
if (addedMarker) {
|
||||
UnstyledIcon(
|
||||
imageVector = Lucide.Plus,
|
||||
contentDescription = stringResource(Res.string.meal_plan_editor_added_marker_a11y),
|
||||
tint = colors.contentMuted,
|
||||
modifier = Modifier.size(AddedMarkerSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconBadgeButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ToggleSize),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
UnstyledIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = RecipeTheme.colors.contentMuted,
|
||||
modifier = Modifier.size(ToggleIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlternativeOption(
|
||||
option: RecipeIngredientOptionUi,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val typography = RecipeTheme.typography
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
backgroundColor = colors.background,
|
||||
contentColor = colors.content,
|
||||
shape = RoundedCornerShape(OptionCornerRadius),
|
||||
contentPadding = PaddingValues(OptionPadding),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
BasicText(
|
||||
text = option.name,
|
||||
style =
|
||||
typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = OptionNameTextSize,
|
||||
lineHeight = OptionNameLineHeight,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(OptionMetaGap))
|
||||
BasicText(
|
||||
text = formatIngredientAmount(option.amount) + " " + option.unit,
|
||||
style =
|
||||
typography.body.copy(
|
||||
color = colors.contentMuted,
|
||||
fontSize = OptionMetaTextSize,
|
||||
lineHeight = OptionMetaLineHeight,
|
||||
),
|
||||
)
|
||||
}
|
||||
SelectionMark(selected = selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectionMark(selected: Boolean) {
|
||||
val colors = RecipeTheme.colors
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SelectionMarkSize)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.border(
|
||||
width = SelectionMarkBorder,
|
||||
color = colors.separator,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
UnstyledIcon(
|
||||
imageVector = Lucide.Check,
|
||||
contentDescription = null,
|
||||
tint = colors.contentMuted,
|
||||
modifier = Modifier.size(SelectionCheckSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val RecipeIngredientSlotUi.options: List<RecipeIngredientOptionUi>
|
||||
get() = listOf(default) + alternatives
|
||||
|
||||
internal fun RecipeIngredientSlotUi.scaledBy(servings: Int) =
|
||||
RecipeIngredientSlotUi(
|
||||
default = default.copy(amount = default.amount * servings),
|
||||
alternatives = alternatives.map { it.copy(amount = it.amount * servings) },
|
||||
id = id,
|
||||
)
|
||||
|
||||
private val MinRowHeight = 48.dp
|
||||
private val PaddingHorizontal = 12.dp
|
||||
private val PaddingVertical = 12.dp
|
||||
private val NameTextSize = 12.sp
|
||||
private val LineHeight = 16.sp
|
||||
private val ToggleSize = 24.dp
|
||||
private val ToggleIconSize = 12.dp
|
||||
private val AddedMarkerSize = 10.dp
|
||||
private val OptionCornerRadius = 10.dp
|
||||
private val OptionPadding = 12.dp
|
||||
private val OptionMetaGap = 2.dp
|
||||
private val OptionNameTextSize = 11.sp
|
||||
private val OptionNameLineHeight = 14.sp
|
||||
private val OptionMetaTextSize = 10.sp
|
||||
private val OptionMetaLineHeight = 13.sp
|
||||
private val SelectionMarkSize = 18.dp
|
||||
private val SelectionMarkBorder = 1.5.dp
|
||||
private val SelectionCheckSize = 10.dp
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.meal_slot_breakfast
|
||||
import recipe.composeapp.generated.resources.meal_slot_dinner
|
||||
import recipe.composeapp.generated.resources.meal_slot_lunch
|
||||
import recipe.composeapp.generated.resources.meal_slot_snack
|
||||
import recipe.composeapp.generated.resources.meal_slot_supper
|
||||
|
||||
/**
|
||||
* Pora posiłku — shared by recipe detail (`allowedSlots`) and the meal-plan
|
||||
* editor (selected slot + filtered chip row). Ordering reflects the canonical
|
||||
* daily sequence used in the UI.
|
||||
*/
|
||||
enum class MealSlot(
|
||||
val labelRes: StringResource,
|
||||
) {
|
||||
Breakfast(Res.string.meal_slot_breakfast),
|
||||
Lunch(Res.string.meal_slot_lunch),
|
||||
Dinner(Res.string.meal_slot_dinner),
|
||||
Supper(Res.string.meal_slot_supper),
|
||||
Snack(Res.string.meal_slot_snack),
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.nutrition_grams_format
|
||||
import recipe.composeapp.generated.resources.nutrition_macro_carbs
|
||||
import recipe.composeapp.generated.resources.nutrition_macro_fat
|
||||
import recipe.composeapp.generated.resources.nutrition_macro_kcal
|
||||
import recipe.composeapp.generated.resources.nutrition_macro_protein
|
||||
|
||||
data class RecipeNutritionUi(
|
||||
val kcal: Int,
|
||||
val protein: Int,
|
||||
val fat: Int,
|
||||
val carbs: Int,
|
||||
)
|
||||
|
||||
internal fun RecipeNutritionUi.scaledBy(servings: Int) =
|
||||
RecipeNutritionUi(
|
||||
kcal = kcal * servings,
|
||||
protein = protein * servings,
|
||||
fat = fat * servings,
|
||||
carbs = carbs * servings,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NutritionSummary(
|
||||
nutrition: RecipeNutritionUi,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
MacroCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = nutrition.kcal.toString(),
|
||||
label = stringResource(Res.string.nutrition_macro_kcal),
|
||||
valueColor = colors.content,
|
||||
)
|
||||
MacroCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = stringResource(Res.string.nutrition_grams_format, nutrition.protein),
|
||||
label = stringResource(Res.string.nutrition_macro_protein),
|
||||
valueColor = colors.macroProtein,
|
||||
)
|
||||
MacroCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = stringResource(Res.string.nutrition_grams_format, nutrition.fat),
|
||||
label = stringResource(Res.string.nutrition_macro_fat),
|
||||
valueColor = colors.macroFat,
|
||||
)
|
||||
MacroCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = stringResource(Res.string.nutrition_grams_format, nutrition.carbs),
|
||||
label = stringResource(Res.string.nutrition_macro_carbs),
|
||||
valueColor = colors.macroCarbs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MacroCard(
|
||||
value: String,
|
||||
label: String,
|
||||
valueColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(RoundedCornerShape(CardCornerRadius))
|
||||
.background(colors.surface)
|
||||
.padding(vertical = RecipeTheme.spacing.sm, horizontal = RecipeTheme.spacing.xs),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BasicText(
|
||||
text = value,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = valueColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = ValueTextSize,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(RecipeTheme.spacing.xs))
|
||||
BasicText(
|
||||
text = label,
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = colors.contentMuted,
|
||||
fontSize = LabelTextSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val CardCornerRadius = 12.dp
|
||||
private val ValueTextSize = 16.sp
|
||||
private val LabelTextSize = 11.sp
|
||||
@@ -0,0 +1,120 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Minus
|
||||
import com.composables.icons.lucide.Plus
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Pill-shaped servings stepper. Flat surface with the standard `colors.surface`
|
||||
* fill and `borderCard` outline — the same visual treatment used by every
|
||||
* static editable control across the app (chips, calendar pill, ingredient
|
||||
* card) so the stepper reads as "part of the page" rather than "floating glass
|
||||
* chrome".
|
||||
*/
|
||||
@Composable
|
||||
fun RecipeServingsStepper(
|
||||
servings: Int,
|
||||
servingsRange: IntRange,
|
||||
decrementContentDescription: String,
|
||||
incrementContentDescription: String,
|
||||
onServingsChange: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(STEPPER_HEIGHT / 2)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.height(STEPPER_HEIGHT)
|
||||
.clip(shape)
|
||||
.background(colors.surface)
|
||||
.border(width = SurfaceBorderWidth, color = colors.borderCard, shape = shape),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
|
||||
) {
|
||||
StepperButton(
|
||||
icon = Lucide.Minus,
|
||||
contentDescription = decrementContentDescription,
|
||||
enabled = servings > servingsRange.first,
|
||||
onClick = { onServingsChange(servings - 1) },
|
||||
)
|
||||
BasicText(
|
||||
text = servings.toString(),
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = SERVINGS_VALUE_TEXT_SIZE,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.width(SERVINGS_VALUE_WIDTH),
|
||||
)
|
||||
StepperButton(
|
||||
icon = Lucide.Plus,
|
||||
contentDescription = incrementContentDescription,
|
||||
enabled = servings < servingsRange.last,
|
||||
onClick = { onServingsChange(servings + 1) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepperButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.width(STEPPER_BUTTON_WIDTH).requiredHeight(STEPPER_TAP_TARGET_HEIGHT),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
UnstyledIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f),
|
||||
modifier = Modifier.size(STEPPER_ICON_SIZE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SurfaceBorderWidth = 1.dp
|
||||
private val STEPPER_HEIGHT = 36.dp
|
||||
private val STEPPER_TAP_TARGET_HEIGHT = 44.dp
|
||||
private val STEPPER_BUTTON_WIDTH = 36.dp
|
||||
private val STEPPER_ICON_SIZE = 14.dp
|
||||
private val SERVINGS_VALUE_WIDTH = 22.dp
|
||||
private val SERVINGS_VALUE_TEXT_SIZE = 13.sp
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.ulfrx.recipe.ui.components.recipe
|
||||
|
||||
data class RecipeUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val cookingMinutes: Int,
|
||||
val nutrition: RecipeNutritionUi,
|
||||
val ingredients: List<RecipeIngredientSlotUi>,
|
||||
val steps: List<String>,
|
||||
val allowedSlots: List<MealSlot>,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.ulfrx.recipe.ui.components.section
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/** Uppercase muted label used as a section header across recipe-domain screens. */
|
||||
@Composable
|
||||
fun SectionTitle(text: String) {
|
||||
BasicText(
|
||||
text = text.uppercase(),
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = RecipeTheme.colors.contentMuted,
|
||||
fontSize = SectionHeaderTextSize,
|
||||
letterSpacing = SectionHeaderTracking,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Section title stacked on top of [content] with a fixed `spacing.lg` gap —
|
||||
* the canonical "header + body" rhythm of the recipe detail and meal-plan
|
||||
* editor sheets.
|
||||
*/
|
||||
@Composable
|
||||
fun Section(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
SectionTitle(text = title)
|
||||
Spacer(Modifier.height(RecipeTheme.spacing.lg))
|
||||
content()
|
||||
}
|
||||
|
||||
private val SectionHeaderTextSize = 11.sp
|
||||
private val SectionHeaderTracking = 1.sp
|
||||
@@ -0,0 +1,158 @@
|
||||
package dev.ulfrx.recipe.ui.components.sheet
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.composables.core.BottomSheetScope
|
||||
import com.composables.core.DragIndication
|
||||
import com.composables.core.ModalBottomSheet
|
||||
import com.composables.core.ModalBottomSheetState
|
||||
import com.composables.core.Scrim
|
||||
import com.composables.core.Sheet
|
||||
import com.composables.core.SheetDetent
|
||||
import com.composables.core.rememberModalBottomSheetState
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.sheet_drag_handle_a11y
|
||||
|
||||
@Composable
|
||||
fun <T : NavKey> RecipeBottomSheet(
|
||||
state: RecipeBottomSheetState<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
entries: EntryProviderScope<T>.() -> Unit,
|
||||
) {
|
||||
val modalSheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.Hidden, detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded))
|
||||
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<T>()
|
||||
val viewModelDecorator = rememberViewModelStoreNavEntryDecorator<T>()
|
||||
|
||||
OpenOrCloseSheetBasedOnVisibility(modalSheetState, state.isOpen)
|
||||
EmitDismissOnUserCancel(modalSheetState, state)
|
||||
|
||||
ModalBottomSheet(state = modalSheetState) {
|
||||
Scrim(
|
||||
scrimColor = SCRIM_COLOR,
|
||||
enter = fadeIn(tween(SCRIM_FADE_MILLIS)),
|
||||
exit = fadeOut(tween(SCRIM_FADE_MILLIS)),
|
||||
)
|
||||
Sheet(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
backgroundColor = RecipeTheme.colors.background,
|
||||
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
|
||||
) {
|
||||
SheetBody {
|
||||
if (state.backStack.isNotEmpty()) {
|
||||
NavDisplay(
|
||||
backStack = state.backStack,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = { state.pop() },
|
||||
entryDecorators = listOf(saveableDecorator, viewModelDecorator),
|
||||
entryProvider = entryProvider(builder = entries),
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetScope.SheetBody(content: @Composable BoxScope.() -> Unit) {
|
||||
val backdrop = rememberGlassBackdropState()
|
||||
val spacing = RecipeTheme.spacing
|
||||
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(SHEET_HEIGHT_FRACTION)
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), content = content)
|
||||
}
|
||||
|
||||
SheetHandle(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = spacing.sm),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OpenOrCloseSheetBasedOnVisibility(
|
||||
modalSheetState: ModalBottomSheetState,
|
||||
visible: Boolean,
|
||||
) {
|
||||
LaunchedEffect(visible) {
|
||||
modalSheetState.targetDetent =
|
||||
if (visible) SheetDetent.FullyExpanded else SheetDetent.Hidden
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T : NavKey> EmitDismissOnUserCancel(
|
||||
modalSheetState: ModalBottomSheetState,
|
||||
state: RecipeBottomSheetState<T>,
|
||||
) {
|
||||
LaunchedEffect(modalSheetState.isIdle, modalSheetState.currentDetent) {
|
||||
if (modalSheetState.isIdle && modalSheetState.currentDetent == SheetDetent.Hidden) {
|
||||
state.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetScope.SheetHandle(modifier: Modifier = Modifier) {
|
||||
val colors = RecipeTheme.colors
|
||||
val label = stringResource(Res.string.sheet_drag_handle_a11y)
|
||||
DragIndication(
|
||||
modifier =
|
||||
modifier
|
||||
.semantics { this.contentDescription = label }
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(colors.surface.copy(alpha = HandleAlpha))
|
||||
.width(HandleWidth)
|
||||
.height(HandleHeight),
|
||||
)
|
||||
}
|
||||
|
||||
private const val SHEET_HEIGHT_FRACTION = 0.92f
|
||||
private const val SCRIM_FADE_MILLIS = 250
|
||||
private const val HandleAlpha = 0.85f
|
||||
|
||||
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
|
||||
private val SHEET_CORNER_RADIUS = 28.dp
|
||||
private val HandleWidth = 36.dp
|
||||
private val HandleHeight = 5.dp
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.ulfrx.recipe.ui.components.sheet
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
@Stable
|
||||
class RecipeBottomSheetState<T : NavKey> {
|
||||
val backStack: SnapshotStateList<T> = mutableStateListOf()
|
||||
|
||||
val isOpen by derivedStateOf { backStack.isNotEmpty() }
|
||||
|
||||
fun push(entry: T) {
|
||||
backStack.add(entry)
|
||||
}
|
||||
|
||||
fun pop() {
|
||||
if (backStack.isNotEmpty()) {
|
||||
backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun open(entry: T) {
|
||||
backStack.clear()
|
||||
backStack.add(entry)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
backStack.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : NavKey> rememberRecipeBottomSheetState(): RecipeBottomSheetState<T> =
|
||||
remember { RecipeBottomSheetState() }
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.ulfrx.recipe.ui.keyboard
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
internal data class KeyboardTransitionState(
|
||||
val currentInset: Dp,
|
||||
val targetInset: Dp,
|
||||
val animationDurationMillis: Int,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal expect fun rememberKeyboardTransitionState(): KeyboardTransitionState
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipes
|
||||
package dev.ulfrx.recipe.ui.screens.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -14,24 +14,17 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.empty_recipes_subtitle
|
||||
import recipe.composeapp.generated.resources.empty_recipes_title
|
||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
||||
import recipe.composeapp.generated.resources.empty_home_subtitle
|
||||
import recipe.composeapp.generated.resources.empty_home_title
|
||||
import recipe.composeapp.generated.resources.shell_tab_home
|
||||
|
||||
/**
|
||||
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
|
||||
* empty body with the recipe catalog grid.
|
||||
*
|
||||
* Search is now shell-wide (see `AppShell` + `ShellSearchViewModel`) — this
|
||||
* screen no longer owns any bottom-chrome state.
|
||||
*/
|
||||
@Composable
|
||||
fun RecipesScreen(viewModel: RecipesViewModel) {
|
||||
fun HomeScreen(viewModel: HomeViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -47,15 +40,15 @@ fun RecipesScreen(viewModel: RecipesViewModel) {
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.shell_tab_recipes),
|
||||
text = stringResource(Res.string.shell_tab_home),
|
||||
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
|
||||
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyState(
|
||||
icon = BottomBarDestination.Recipes.icon,
|
||||
title = stringResource(Res.string.empty_recipes_title),
|
||||
subtitle = stringResource(Res.string.empty_recipes_subtitle),
|
||||
icon = DockDestination.Home.icon,
|
||||
title = stringResource(Res.string.empty_home_title),
|
||||
subtitle = stringResource(Res.string.empty_home_subtitle),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Plus
|
||||
import com.composables.icons.lucide.Search
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientAmount
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_add_ingredient
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_add_ingredient_cancel
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_add_ingredient_empty
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_add_ingredient_search_placeholder
|
||||
|
||||
/**
|
||||
* "Dodaj składnik" affordance — collapsed dashed button by default; expands
|
||||
* into a search panel with filtering against [catalog]. Already-used recipe /
|
||||
* added ingredient ids are filtered out by [usedIngredientIds] so the user
|
||||
* never sees the same ingredient twice. Open/closed and the in-flight query
|
||||
* are pure UI state — survived across recompositions via [rememberSaveable]
|
||||
* but never lifted into the ViewModel since neither flag matters to confirm.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddIngredientPanel(
|
||||
catalog: List<AddableIngredientUi>,
|
||||
usedIngredientIds: Set<String>,
|
||||
onPick: (AddableIngredientUi) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxResults: Int = 20,
|
||||
keyboardClearance: Dp = 0.dp,
|
||||
autoFocusEnabled: Boolean = true,
|
||||
keyboardAnimationDurationMillis: Int = DefaultKeyboardAnimationDurationMillis,
|
||||
onOpenChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
var isOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val panelAnimationDurationMillis =
|
||||
keyboardAnimationDurationMillis.coerceAtLeast(MinPanelAnimationDurationMillis)
|
||||
|
||||
LaunchedEffect(isOpen) {
|
||||
if (isOpen) {
|
||||
onOpenChange(true)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
AnimatedVisibility(
|
||||
visible = !isOpen,
|
||||
enter =
|
||||
fadeIn(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
|
||||
expandVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
|
||||
exit =
|
||||
fadeOut(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
|
||||
shrinkVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
|
||||
) {
|
||||
AddIngredientCollapsedButton(
|
||||
onClick = {
|
||||
isOpen = true
|
||||
onOpenChange(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = isOpen,
|
||||
enter =
|
||||
fadeIn(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
|
||||
expandVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
|
||||
exit =
|
||||
fadeOut(animationSpec = tween(durationMillis = panelAnimationDurationMillis)) +
|
||||
shrinkVertically(animationSpec = tween(durationMillis = panelAnimationDurationMillis)),
|
||||
) {
|
||||
AddIngredientSearchCard(
|
||||
catalog = catalog,
|
||||
usedIngredientIds = usedIngredientIds,
|
||||
query = query,
|
||||
onSetQuery = { query = it },
|
||||
onClose = {
|
||||
focusManager.clearFocus(force = true)
|
||||
isOpen = false
|
||||
onOpenChange(false)
|
||||
query = ""
|
||||
},
|
||||
onPick = { picked ->
|
||||
focusManager.clearFocus(force = true)
|
||||
onPick(picked)
|
||||
isOpen = false
|
||||
onOpenChange(false)
|
||||
query = ""
|
||||
},
|
||||
maxResults = maxResults,
|
||||
keyboardClearance = keyboardClearance,
|
||||
autoFocusEnabled = autoFocusEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddIngredientCollapsedButton(onClick: () -> Unit) {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(CollapsedCornerRadius)
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
backgroundColor = Color.Transparent,
|
||||
contentColor = colors.contentMuted,
|
||||
shape = shape,
|
||||
contentPadding = PaddingValues(vertical = CollapsedVerticalPadding),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(width = 1.dp, color = colors.borderCard, shape = shape),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
|
||||
) {
|
||||
UnstyledIcon(
|
||||
imageVector = Lucide.Plus,
|
||||
contentDescription = null,
|
||||
tint = colors.contentMuted,
|
||||
modifier = Modifier.size(CollapsedIconSize),
|
||||
)
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_add_ingredient),
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = colors.contentMuted,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = CollapsedTextSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddIngredientSearchCard(
|
||||
catalog: List<AddableIngredientUi>,
|
||||
usedIngredientIds: Set<String>,
|
||||
query: String,
|
||||
onSetQuery: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onPick: (AddableIngredientUi) -> Unit,
|
||||
maxResults: Int,
|
||||
keyboardClearance: Dp,
|
||||
autoFocusEnabled: Boolean,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(CardCornerRadius)
|
||||
val density = LocalDensity.current
|
||||
val panelBringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var panelSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var focusRequested by remember { mutableStateOf(false) }
|
||||
val results = remember(catalog, usedIngredientIds, query, maxResults) {
|
||||
filterCatalog(catalog, usedIngredientIds, query, maxResults)
|
||||
}
|
||||
|
||||
LaunchedEffect(panelSize, keyboardClearance, autoFocusEnabled) {
|
||||
if (panelSize == IntSize.Zero || !autoFocusEnabled) return@LaunchedEffect
|
||||
if (!focusRequested) {
|
||||
focusRequested = true
|
||||
focusRequester.requestFocus()
|
||||
withFrameNanos { }
|
||||
}
|
||||
val rect =
|
||||
with(density) {
|
||||
panelSize.panelVisibilityRect(keyboardClearancePx = keyboardClearance.toPx())
|
||||
}
|
||||
panelBringIntoViewRequester.bringIntoView(rect)
|
||||
withFrameNanos { }
|
||||
panelBringIntoViewRequester.bringIntoView(rect)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(panelBringIntoViewRequester)
|
||||
.onSizeChanged { panelSize = it }
|
||||
.clip(shape)
|
||||
.background(colors.surface)
|
||||
.border(width = 1.dp, color = colors.borderCard, shape = shape)
|
||||
.padding(RecipeTheme.spacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
SearchRow(
|
||||
query = query,
|
||||
onQueryChange = onSetQuery,
|
||||
onCancel = onClose,
|
||||
focusRequester = focusRequester,
|
||||
)
|
||||
if (results.isEmpty()) {
|
||||
EmptyResultsMessage()
|
||||
} else {
|
||||
ResultsList(results = results, onPick = onPick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchRow(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
SearchInputField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
focusRequester = focusRequester,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
UnstyledButton(
|
||||
onClick = onCancel,
|
||||
backgroundColor = Color.Transparent,
|
||||
contentColor = colors.contentMuted,
|
||||
contentPadding = PaddingValues(horizontal = RecipeTheme.spacing.sm),
|
||||
) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_add_ingredient_cancel),
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = colors.contentMuted,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = CancelTextSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchInputField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(SearchInputCornerRadius)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.height(SearchInputHeight)
|
||||
.clip(shape)
|
||||
.background(colors.background)
|
||||
.border(width = 1.dp, color = colors.borderCard, shape = shape)
|
||||
.padding(horizontal = RecipeTheme.spacing.sm),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
UnstyledIcon(
|
||||
imageVector = Lucide.Search,
|
||||
contentDescription = null,
|
||||
tint = colors.contentMuted,
|
||||
modifier = Modifier.size(SearchIconSize),
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(colors.accent),
|
||||
textStyle =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
fontSize = SearchInputTextSize,
|
||||
),
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().focusRequester(focusRequester),
|
||||
decorationBox = { inner ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight().fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_add_ingredient_search_placeholder),
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.contentMuted,
|
||||
fontSize = SearchInputTextSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
inner()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultsList(
|
||||
results: List<AddableIngredientUi>,
|
||||
onPick: (AddableIngredientUi) -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(ResultsCardCornerRadius)
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = ResultsListMaxHeight)
|
||||
.clip(shape)
|
||||
.background(colors.background)
|
||||
.border(width = 1.dp, color = colors.borderCard, shape = shape)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
results.forEachIndexed { index, ingredient ->
|
||||
if (index > 0) IngredientDivider()
|
||||
ResultRow(ingredient = ingredient, onClick = { onPick(ingredient) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultRow(
|
||||
ingredient: AddableIngredientUi,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
backgroundColor = Color.Transparent,
|
||||
contentColor = colors.content,
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
horizontal = ResultRowHorizontalPadding,
|
||||
vertical = ResultRowVerticalPadding,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = ResultRowMinHeight),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
BasicText(
|
||||
text = ingredient.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = ResultRowTextSize,
|
||||
),
|
||||
)
|
||||
IngredientAmount(amount = ingredient.defaultAmount, unit = ingredient.defaultUnit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyResultsMessage() {
|
||||
val colors = RecipeTheme.colors
|
||||
val shape = RoundedCornerShape(ResultsCardCornerRadius)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(colors.background)
|
||||
.border(width = 1.dp, color = colors.borderCard, shape = shape)
|
||||
.padding(vertical = EmptyMessagePadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_add_ingredient_empty),
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = colors.contentMuted,
|
||||
fontSize = ResultRowTextSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterCatalog(
|
||||
catalog: List<AddableIngredientUi>,
|
||||
usedIngredientIds: Set<String>,
|
||||
query: String,
|
||||
maxResults: Int,
|
||||
): List<AddableIngredientUi> {
|
||||
val needle = query.trim().lowercase()
|
||||
return catalog.asSequence()
|
||||
.filter { it.ingredientId !in usedIngredientIds }
|
||||
.filter { needle.isEmpty() || it.name.lowercase().contains(needle) }
|
||||
.take(maxResults)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun IntSize.panelVisibilityRect(keyboardClearancePx: Float): Rect =
|
||||
Rect(
|
||||
left = 0f,
|
||||
top = 0f,
|
||||
right = width.toFloat(),
|
||||
bottom = height.toFloat() + keyboardClearancePx,
|
||||
)
|
||||
|
||||
private val CollapsedCornerRadius = 12.dp
|
||||
private val CollapsedVerticalPadding = 10.dp
|
||||
private val CollapsedIconSize = 12.dp
|
||||
private val CollapsedTextSize = 12.sp
|
||||
|
||||
private val CardCornerRadius = 14.dp
|
||||
private val CancelTextSize = 11.sp
|
||||
private const val DefaultKeyboardAnimationDurationMillis = 250
|
||||
private const val MinPanelAnimationDurationMillis = 120
|
||||
|
||||
private val SearchInputHeight = 36.dp
|
||||
private val SearchInputCornerRadius = 10.dp
|
||||
private val SearchInputTextSize = 13.sp
|
||||
private val SearchIconSize = 14.dp
|
||||
|
||||
private val ResultsListMaxHeight = 200.dp
|
||||
// Smaller than IngredientCard's 16dp — nested inside the search card, deserves a tighter corner.
|
||||
private val ResultsCardCornerRadius = 12.dp
|
||||
private val ResultRowHorizontalPadding = 12.dp
|
||||
private val ResultRowVerticalPadding = 8.dp
|
||||
// Smaller than IngredientRow's 48dp min — these rows show only a name, no swap/amount affordances.
|
||||
private val ResultRowMinHeight = 40.dp
|
||||
private val ResultRowTextSize = 12.sp
|
||||
private val EmptyMessagePadding = 14.dp
|
||||
@@ -0,0 +1,147 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientCard
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_removed_format
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_removed_restore
|
||||
|
||||
/**
|
||||
* Wrapping card with one row per visible ingredient — both the recipe's
|
||||
* (minus excluded) and the user-added ones — plus the "X usuniętych —
|
||||
* Przywróć" bar appended below the card. Reuses the shared [IngredientRow]
|
||||
* so the visual language matches the read-only detail screen exactly.
|
||||
*/
|
||||
@Composable
|
||||
internal fun IngredientEditorList(
|
||||
recipeIngredients: List<RecipeIngredientSlotUi>,
|
||||
addedIngredients: List<AddedIngredientUi>,
|
||||
excludedIngredientIds: Set<String>,
|
||||
substitutions: Map<String, String>,
|
||||
servings: Int,
|
||||
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
||||
onRemoveRecipeIngredient: (slotId: String) -> Unit,
|
||||
onRemoveAddedIngredient: (ingredientId: String) -> Unit,
|
||||
onRestoreRemoved: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val visibleRecipeIngredients =
|
||||
remember(recipeIngredients, excludedIngredientIds) {
|
||||
recipeIngredients.filter { it.id !in excludedIngredientIds }
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
IngredientCard {
|
||||
visibleRecipeIngredients.forEachIndexed { index, slot ->
|
||||
if (index > 0) IngredientDivider()
|
||||
val scaledSlot = remember(slot, servings) { slot.scaledBy(servings) }
|
||||
IngredientRow(
|
||||
slot = scaledSlot,
|
||||
selectedOptionId = substitutions[slot.id] ?: slot.default.id,
|
||||
onSelect =
|
||||
if (slot.alternatives.isNotEmpty()) {
|
||||
{ choice -> onSelectSubstitution(slot.id, choice.id) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onRemove = { onRemoveRecipeIngredient(slot.id) },
|
||||
)
|
||||
}
|
||||
addedIngredients.forEachIndexed { index, added ->
|
||||
if (visibleRecipeIngredients.isNotEmpty() || index > 0) IngredientDivider()
|
||||
val scaledSlot = remember(added, servings) { added.toScaledSyntheticSlot(servings) }
|
||||
IngredientRow(
|
||||
slot = scaledSlot,
|
||||
addedMarker = true,
|
||||
onRemove = { onRemoveAddedIngredient(added.ingredientId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (excludedIngredientIds.isNotEmpty()) {
|
||||
RemovedBar(
|
||||
count = excludedIngredientIds.size,
|
||||
onRestore = onRestoreRemoved,
|
||||
modifier = Modifier.padding(top = RecipeTheme.spacing.sm),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemovedBar(
|
||||
count: Int,
|
||||
onRestore: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = RemovedBarHorizontalInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_removed_format, count),
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = colors.contentMuted,
|
||||
fontSize = RemovedBarTextSize,
|
||||
),
|
||||
)
|
||||
UnstyledButton(
|
||||
onClick = onRestore,
|
||||
contentColor = colors.content,
|
||||
backgroundColor = Color.Transparent,
|
||||
) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_removed_restore),
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = RemovedBarTextSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AddedIngredientUi.toScaledSyntheticSlot(servings: Int): RecipeIngredientSlotUi =
|
||||
RecipeIngredientSlotUi(
|
||||
default =
|
||||
RecipeIngredientOptionUi(
|
||||
id = ingredientId,
|
||||
name = name,
|
||||
amount = amount * servings,
|
||||
unit = unit,
|
||||
),
|
||||
alternatives = emptyList(),
|
||||
id = "added:$ingredientId",
|
||||
)
|
||||
|
||||
private val RemovedBarHorizontalInset = 4.dp
|
||||
private val RemovedBarTextSize = 11.sp
|
||||
@@ -0,0 +1,267 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.ulfrx.recipe.ui.components.calendar.CalendarPillExpandDirection
|
||||
import dev.ulfrx.recipe.ui.components.calendar.RecipeCalendarPill
|
||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
||||
import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeServingsStepper
|
||||
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
|
||||
import dev.ulfrx.recipe.ui.components.section.SectionTitle
|
||||
import dev.ulfrx.recipe.ui.keyboard.rememberKeyboardTransitionState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_title
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_section_ingredients
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_section_servings
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_section_slot
|
||||
import recipe.composeapp.generated.resources.nutrition_label
|
||||
import recipe.composeapp.generated.resources.recipe_detail_servings_decrement_a11y
|
||||
import recipe.composeapp.generated.resources.recipe_detail_servings_increment_a11y
|
||||
|
||||
@Composable
|
||||
internal fun MealPlanEditorContent(
|
||||
editing: MealPlanEditorState.Editing,
|
||||
catalog: List<AddableIngredientUi>,
|
||||
topChromeInset: Dp,
|
||||
topChromeHeight: Dp,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
onSetCalendarExpanded: (Boolean) -> Unit,
|
||||
onSelectSlot: (MealSlot) -> Unit,
|
||||
onSetServings: (Int) -> Unit,
|
||||
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
||||
onRemoveRecipeIngredient: (slotId: String) -> Unit,
|
||||
onRemoveAddedIngredient: (ingredientId: String) -> Unit,
|
||||
onRestoreRemoved: () -> Unit,
|
||||
onAddIngredient: (AddableIngredientUi) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = RecipeTheme.spacing
|
||||
val scrollState = rememberScrollState()
|
||||
var addPanelOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val navigationInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val keyboardTransition = rememberKeyboardTransitionState()
|
||||
|
||||
val keyboardReserve =
|
||||
when {
|
||||
addPanelOpen -> maxOf(keyboardTransition.currentInset, keyboardTransition.targetInset)
|
||||
keyboardTransition.currentInset > navigationInset -> keyboardTransition.currentInset
|
||||
else -> 0.dp
|
||||
}
|
||||
val bottomInset = maxOf(navigationInset, keyboardReserve)
|
||||
|
||||
val scaledNutrition =
|
||||
remember(editing.recipe.nutrition, editing.servings) {
|
||||
editing.recipe.nutrition.scaledBy(editing.servings)
|
||||
}
|
||||
val usedIngredientIds =
|
||||
remember(editing.addedIngredients) {
|
||||
editing.addedIngredients.mapTo(mutableSetOf()) { it.ingredientId }
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background)) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState, enabled = !addPanelOpen),
|
||||
) {
|
||||
Spacer(Modifier.height(topChromeInset))
|
||||
// Aligns the title row with the floating back/confirm chrome at
|
||||
// scroll=0: same height, padded inside the chrome's circle pills.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(topChromeHeight)
|
||||
.padding(horizontal = spacing.lg + topChromeHeight + spacing.sm),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
RecipeTitle(recipeTitle = editing.recipe.title)
|
||||
}
|
||||
Spacer(Modifier.height(spacing.xl))
|
||||
RecipeCalendarPill(
|
||||
selectedDate = editing.selectedDate,
|
||||
expanded = editing.calendarExpanded,
|
||||
onExpandedChange = onSetCalendarExpanded,
|
||||
onSelectDate = onSelectDate,
|
||||
onSelectionShift = onSelectDate,
|
||||
expandDirection = CalendarPillExpandDirection.Down,
|
||||
glass = false,
|
||||
tint = RecipeTheme.colors.surface,
|
||||
modifier = Modifier.padding(horizontal = spacing.lg),
|
||||
)
|
||||
|
||||
SectionContainer {
|
||||
SectionTitle(text = stringResource(Res.string.meal_plan_editor_section_slot))
|
||||
Spacer(Modifier.height(spacing.sm))
|
||||
MealSlotChipsRow(
|
||||
allSlots = MealSlot.entries,
|
||||
allowedSlots = editing.recipe.allowedSlots,
|
||||
selectedSlot = editing.selectedSlot,
|
||||
onSelectSlot = onSelectSlot,
|
||||
)
|
||||
}
|
||||
|
||||
SectionContainer {
|
||||
SectionTitle(text = stringResource(Res.string.nutrition_label))
|
||||
Spacer(Modifier.height(spacing.sm))
|
||||
NutritionSummary(
|
||||
nutrition = scaledNutrition,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
SectionContainer {
|
||||
ServingsRow(
|
||||
servings = editing.servings,
|
||||
onServingsChange = onSetServings,
|
||||
)
|
||||
}
|
||||
|
||||
SectionContainer {
|
||||
SectionTitle(text = stringResource(Res.string.meal_plan_editor_section_ingredients))
|
||||
Spacer(Modifier.height(spacing.sm))
|
||||
IngredientEditorList(
|
||||
recipeIngredients = editing.recipe.ingredients,
|
||||
addedIngredients = editing.addedIngredients,
|
||||
excludedIngredientIds = editing.excludedIngredients,
|
||||
substitutions = editing.substitutions,
|
||||
servings = editing.servings,
|
||||
onSelectSubstitution = onSelectSubstitution,
|
||||
onRemoveRecipeIngredient = onRemoveRecipeIngredient,
|
||||
onRemoveAddedIngredient = onRemoveAddedIngredient,
|
||||
onRestoreRemoved = onRestoreRemoved,
|
||||
)
|
||||
Spacer(Modifier.height(spacing.sm))
|
||||
AddIngredientPanel(
|
||||
catalog = catalog,
|
||||
usedIngredientIds = usedIngredientIds,
|
||||
onPick = onAddIngredient,
|
||||
keyboardClearance = keyboardReserve + spacing.sm,
|
||||
autoFocusEnabled = addPanelOpen,
|
||||
keyboardAnimationDurationMillis = keyboardTransition.animationDurationMillis,
|
||||
onOpenChange = { addPanelOpen = it },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(bottomInset + spacing.xxl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServingsRow(
|
||||
servings: Int,
|
||||
onServingsChange: (Int) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
SectionTitle(text = stringResource(Res.string.meal_plan_editor_section_servings))
|
||||
RecipeServingsStepper(
|
||||
servings = servings,
|
||||
servingsRange = MIN_PLAN_SERVINGS..MAX_PLAN_SERVINGS,
|
||||
decrementContentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
|
||||
incrementContentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
|
||||
onServingsChange = onServingsChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionContainer(content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = RecipeTheme.spacing.lg,
|
||||
end = RecipeTheme.spacing.lg,
|
||||
top = RecipeTheme.spacing.xl,
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipeTitle(
|
||||
recipeTitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_title),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = RecipeTheme.colors.content,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = RecipeTitleSize,
|
||||
lineHeight = RecipeTitleLineHeight,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(RecipeTitleGap))
|
||||
BasicText(
|
||||
text = recipeTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = RecipeTheme.colors.contentMuted,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = RecipeSubtitleSize,
|
||||
lineHeight = RecipeSubtitleLineHeight,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val RecipeTitleSize = 16.sp
|
||||
private val RecipeTitleLineHeight = 17.sp
|
||||
private val RecipeTitleGap = 4.dp
|
||||
private val RecipeSubtitleSize = 11.sp
|
||||
private val RecipeSubtitleLineHeight = 14.sp
|
||||
@@ -0,0 +1,125 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Plus
|
||||
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.meal_plan_editor_back_a11y
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_confirm_a11y
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_not_found
|
||||
|
||||
@Composable
|
||||
internal fun MealPlanEditorScreen(
|
||||
viewModel: MealPlanEditorViewModel,
|
||||
onBack: () -> Unit,
|
||||
onConfirm: (PlannedMealUi) -> Unit,
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val spacing = RecipeTheme.spacing
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (val s = state) {
|
||||
is MealPlanEditorState.Editing -> {
|
||||
MealPlanEditorContent(
|
||||
editing = s,
|
||||
catalog = sampleAddableIngredients,
|
||||
topChromeInset = TopActionsTopInset,
|
||||
topChromeHeight = TopPillHeight,
|
||||
onSelectDate = viewModel::selectDate,
|
||||
onSetCalendarExpanded = viewModel::setCalendarExpanded,
|
||||
onSelectSlot = viewModel::selectSlot,
|
||||
onSetServings = viewModel::setServings,
|
||||
onSelectSubstitution = viewModel::selectSubstitution,
|
||||
onRemoveRecipeIngredient = viewModel::removeRecipeIngredient,
|
||||
onRemoveAddedIngredient = viewModel::removeAddedIngredient,
|
||||
onRestoreRemoved = viewModel::restoreRemovedIngredients,
|
||||
onAddIngredient = viewModel::addIngredient,
|
||||
)
|
||||
|
||||
EditorChromeRow(
|
||||
showConfirm = true,
|
||||
onBack = onBack,
|
||||
onConfirm = { viewModel.confirm()?.let(onConfirm) },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
|
||||
)
|
||||
}
|
||||
|
||||
MealPlanEditorState.NotFound -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.meal_plan_editor_not_found),
|
||||
style = RecipeTheme.typography.body,
|
||||
)
|
||||
}
|
||||
|
||||
EditorChromeRow(
|
||||
showConfirm = false,
|
||||
onBack = onBack,
|
||||
onConfirm = {},
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(top = TopActionsTopInset, start = spacing.lg, end = spacing.lg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorChromeRow(
|
||||
showConfirm: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircleGlassButton(
|
||||
onClick = onBack,
|
||||
icon = Lucide.ArrowLeft,
|
||||
contentDescription = stringResource(Res.string.meal_plan_editor_back_a11y),
|
||||
size = TopPillHeight,
|
||||
iconSize = TopActionIconSize,
|
||||
glassStyle = RecipeTheme.glass.button,
|
||||
)
|
||||
if (showConfirm) {
|
||||
CircleGlassButton(
|
||||
onClick = onConfirm,
|
||||
icon = Lucide.Plus,
|
||||
contentDescription = stringResource(Res.string.meal_plan_editor_confirm_a11y),
|
||||
size = TopPillHeight,
|
||||
iconSize = TopActionIconSize,
|
||||
glassStyle = RecipeTheme.glass.button,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TopPillHeight = 44.dp
|
||||
private val TopActionIconSize = 18.dp
|
||||
private val TopActionsTopInset = 28.dp
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
internal const val MIN_PLAN_SERVINGS = 1
|
||||
internal const val MAX_PLAN_SERVINGS = 12
|
||||
|
||||
sealed interface MealPlanEditorState {
|
||||
data object NotFound : MealPlanEditorState
|
||||
|
||||
data class Editing(
|
||||
val id: String,
|
||||
val recipe: RecipeUi,
|
||||
val selectedDate: LocalDate,
|
||||
val selectedSlot: MealSlot,
|
||||
val calendarExpanded: Boolean = false,
|
||||
val servings: Int = MIN_PLAN_SERVINGS,
|
||||
val substitutions: Map<String, String> = emptyMap(),
|
||||
val excludedIngredients: Set<String> = emptySet(),
|
||||
val addedIngredients: List<AddedIngredientUi> = emptyList(),
|
||||
) : MealPlanEditorState
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
data class AddedIngredientUi(
|
||||
val ingredientId: String,
|
||||
val name: String,
|
||||
val amount: Double,
|
||||
val unit: String,
|
||||
)
|
||||
|
||||
data class AddableIngredientUi(
|
||||
val ingredientId: String,
|
||||
val name: String,
|
||||
val defaultAmount: Double,
|
||||
val defaultUnit: String,
|
||||
)
|
||||
|
||||
data class PlannedMealUi(
|
||||
val id: String,
|
||||
val recipeId: String,
|
||||
val date: LocalDate,
|
||||
val slot: MealSlot,
|
||||
val servings: Int,
|
||||
val substitutions: Map<String, String>,
|
||||
val excludedIngredients: Set<String>,
|
||||
val addedIngredients: List<AddedIngredientUi>,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
|
||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.options
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class MealPlanEditorViewModel(
|
||||
source: MealPlanEditorSource,
|
||||
recipeProvider: (String) -> RecipeUi?,
|
||||
plannedMealProvider: (String) -> PlannedMealUi?,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(loadInitial(source, recipeProvider, plannedMealProvider))
|
||||
val state: StateFlow<MealPlanEditorState> = _state.asStateFlow()
|
||||
|
||||
fun confirm(): PlannedMealUi? {
|
||||
val editing = _state.value as? MealPlanEditorState.Editing ?: return null
|
||||
return PlannedMealUi(
|
||||
id = editing.id,
|
||||
recipeId = editing.recipe.id,
|
||||
date = editing.selectedDate,
|
||||
slot = editing.selectedSlot,
|
||||
servings = editing.servings,
|
||||
substitutions = editing.substitutions,
|
||||
excludedIngredients = editing.excludedIngredients,
|
||||
addedIngredients = editing.addedIngredients,
|
||||
)
|
||||
}
|
||||
|
||||
fun selectDate(date: LocalDate) = updateEditing { it.copy(selectedDate = date) }
|
||||
|
||||
fun setCalendarExpanded(expanded: Boolean) = updateEditing { it.copy(calendarExpanded = expanded) }
|
||||
|
||||
fun selectSlot(slot: MealSlot) =
|
||||
updateEditing {
|
||||
if (slot in it.recipe.allowedSlots) it.copy(selectedSlot = slot) else it
|
||||
}
|
||||
|
||||
fun setServings(value: Int) =
|
||||
updateEditing { it.copy(servings = value.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS)) }
|
||||
|
||||
fun selectSubstitution(
|
||||
slotId: String,
|
||||
optionId: String,
|
||||
) = updateEditing { editing ->
|
||||
val slot = editing.recipe.ingredients.firstOrNull { it.id == slotId } ?: return@updateEditing editing
|
||||
if (slot.options.none { it.id == optionId }) return@updateEditing editing
|
||||
|
||||
val substitutions =
|
||||
if (optionId == slot.default.id) {
|
||||
editing.substitutions - slotId
|
||||
} else {
|
||||
editing.substitutions + (slotId to optionId)
|
||||
}
|
||||
editing.copy(substitutions = substitutions)
|
||||
}
|
||||
|
||||
fun removeRecipeIngredient(slotId: String) =
|
||||
updateEditing { it.copy(excludedIngredients = it.excludedIngredients + slotId) }
|
||||
|
||||
fun restoreRemovedIngredients() =
|
||||
updateEditing { it.copy(excludedIngredients = emptySet()) }
|
||||
|
||||
fun addIngredient(ingredient: AddableIngredientUi) =
|
||||
updateEditing { editing ->
|
||||
if (editing.addedIngredients.any { it.ingredientId == ingredient.ingredientId }) {
|
||||
editing
|
||||
} else {
|
||||
editing.copy(addedIngredients = editing.addedIngredients + ingredient.toAdded())
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddedIngredient(ingredientId: String) =
|
||||
updateEditing { it.copy(addedIngredients = it.addedIngredients.filterNot { added -> added.ingredientId == ingredientId }) }
|
||||
|
||||
private inline fun updateEditing(crossinline transform: (MealPlanEditorState.Editing) -> MealPlanEditorState.Editing) {
|
||||
_state.update { current ->
|
||||
if (current is MealPlanEditorState.Editing) transform(current) else current
|
||||
}
|
||||
}
|
||||
|
||||
private fun AddableIngredientUi.toAdded() =
|
||||
AddedIngredientUi(
|
||||
ingredientId = ingredientId,
|
||||
name = name,
|
||||
amount = defaultAmount,
|
||||
unit = defaultUnit,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun loadInitial(
|
||||
source: MealPlanEditorSource,
|
||||
recipeProvider: (String) -> RecipeUi?,
|
||||
plannedMealProvider: (String) -> PlannedMealUi?,
|
||||
): MealPlanEditorState =
|
||||
when (source) {
|
||||
is MealPlanEditorSource.NewFromRecipe -> {
|
||||
val recipe = recipeProvider(source.recipeId)
|
||||
if (recipe == null) {
|
||||
MealPlanEditorState.NotFound
|
||||
} else {
|
||||
MealPlanEditorState.Editing(
|
||||
id = "plan_${Uuid.random()}",
|
||||
recipe = recipe,
|
||||
selectedDate = todayInSystemTz(),
|
||||
selectedSlot = recipe.allowedSlots.firstOrNull() ?: MealSlot.entries.first(),
|
||||
servings = source.initialServings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
|
||||
substitutions = source.initialSubstitutions.filterValid(recipe),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is MealPlanEditorSource.EditExistingPlan -> {
|
||||
val planned = plannedMealProvider(source.plannedMealId)
|
||||
val recipe = planned?.let { recipeProvider(it.recipeId) }
|
||||
if (planned == null || recipe == null) {
|
||||
MealPlanEditorState.NotFound
|
||||
} else {
|
||||
MealPlanEditorState.Editing(
|
||||
id = planned.id,
|
||||
recipe = recipe,
|
||||
selectedDate = planned.date,
|
||||
selectedSlot = planned.slot,
|
||||
servings = planned.servings.coerceIn(MIN_PLAN_SERVINGS, MAX_PLAN_SERVINGS),
|
||||
substitutions = planned.substitutions.filterValid(recipe),
|
||||
excludedIngredients = planned.excludedIngredients,
|
||||
addedIngredients = planned.addedIngredients,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, String>.filterValid(recipe: RecipeUi): Map<String, String> =
|
||||
filter { (slotId, optionId) ->
|
||||
val slot = recipe.ingredients.firstOrNull { it.id == slotId }
|
||||
slot != null && slot.options.any { it.id == optionId }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.ulfrx.recipe.ui.components.chips.MealSlotChip
|
||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Renders every meal slot as a chip; slots outside [allowedSlots] are visible
|
||||
* but disabled (recipe-specific availability signal). Selection is single-pick.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MealSlotChipsRow(
|
||||
allSlots: List<MealSlot>,
|
||||
allowedSlots: List<MealSlot>,
|
||||
selectedSlot: MealSlot,
|
||||
onSelectSlot: (MealSlot) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
allSlots.forEach { slot ->
|
||||
val enabled = slot in allowedSlots
|
||||
MealSlotChip(
|
||||
label = stringResource(slot.labelRes),
|
||||
selected = slot == selectedSlot,
|
||||
enabled = enabled,
|
||||
onClick = { onSelectSlot(slot) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.ulfrx.recipe.ui.screens.mealplaneditor
|
||||
|
||||
/**
|
||||
* UI-only stand-in for the future ingredient catalog (Phase 8 pantry +
|
||||
* Phase 6 planner reach into the real INGREDIENTS index). Names match the
|
||||
* pool used by sample recipes so the search panel feels populated.
|
||||
*/
|
||||
internal val sampleAddableIngredients: List<AddableIngredientUi> =
|
||||
listOf(
|
||||
addable("ing_cynamon", "Cynamon", 1.0, "łyżeczka"),
|
||||
addable("ing_jogurt", "Jogurt naturalny", 100.0, "g"),
|
||||
addable("ing_maslo_orzechowe", "Masło orzechowe", 15.0, "g"),
|
||||
addable("ing_rodzynki", "Rodzynki", 15.0, "g"),
|
||||
addable("ing_kakao", "Kakao", 5.0, "g"),
|
||||
addable("ing_nasiona_chia", "Nasiona chia", 1.0, "łyżka"),
|
||||
addable("ing_siemie_lniane", "Siemię lniane", 1.0, "łyżka"),
|
||||
addable("ing_orzechy_nerkowca", "Orzechy nerkowca", 15.0, "g"),
|
||||
addable("ing_pestki_dyni", "Pestki dyni", 10.0, "g"),
|
||||
addable("ing_pestki_slonecznika", "Pestki słonecznika", 10.0, "g"),
|
||||
addable("ing_daktyle", "Daktyle suszone", 20.0, "g"),
|
||||
addable("ing_kokos_wiorki", "Wiórki kokosowe", 10.0, "g"),
|
||||
addable("ing_imbir", "Imbir świeży", 5.0, "g"),
|
||||
addable("ing_kurkuma", "Kurkuma", 1.0, "łyżeczka"),
|
||||
addable("ing_papryka_slodka", "Papryka słodka", 1.0, "łyżeczka"),
|
||||
addable("ing_oliwa", "Oliwa", 10.0, "ml"),
|
||||
addable("ing_oct_balsamiczny", "Ocet balsamiczny", 5.0, "ml"),
|
||||
addable("ing_musztarda", "Musztarda", 5.0, "g"),
|
||||
addable("ing_majeranek", "Majeranek", 1.0, "łyżeczka"),
|
||||
addable("ing_oregano", "Oregano", 1.0, "łyżeczka"),
|
||||
addable("ing_bazylia", "Bazylia świeża", 5.0, "g"),
|
||||
addable("ing_pietruszka_nat", "Natka pietruszki", 5.0, "g"),
|
||||
addable("ing_kapary", "Kapary", 10.0, "g"),
|
||||
addable("ing_oliwki_zielone", "Oliwki zielone", 30.0, "g"),
|
||||
addable("ing_pomidorki_koktajlowe", "Pomidorki koktajlowe", 80.0, "g"),
|
||||
addable("ing_rukola", "Rukola", 20.0, "g"),
|
||||
addable("ing_szpinak_baby", "Szpinak baby", 30.0, "g"),
|
||||
addable("ing_quinoa", "Komosa ryżowa", 60.0, "g"),
|
||||
addable("ing_kasza_gryczana", "Kasza gryczana", 60.0, "g"),
|
||||
)
|
||||
|
||||
private fun addable(
|
||||
id: String,
|
||||
name: String,
|
||||
amount: Double,
|
||||
unit: String,
|
||||
) = AddableIngredientUi(
|
||||
ingredientId = id,
|
||||
name = name,
|
||||
defaultAmount = amount,
|
||||
defaultUnit = unit,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.ulfrx.recipe.ui.screens.pantry
|
||||
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.pantry_shortfall_count
|
||||
|
||||
@Composable
|
||||
fun PantryHorizonPill(
|
||||
selectedDate: LocalDate,
|
||||
expanded: Boolean,
|
||||
today: LocalDate,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
HorizonCalendarPill(
|
||||
selectedDate = selectedDate,
|
||||
expanded = expanded,
|
||||
today = today,
|
||||
onExpandedChange = onExpandedChange,
|
||||
onSelectDate = onSelectDate,
|
||||
trailing = {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.pantry_shortfall_count, DUMMY_SHORTFALLS),
|
||||
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.destructive),
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DUMMY_SHORTFALLS = 7
|
||||
@@ -1,6 +1,5 @@
|
||||
package dev.ulfrx.recipe.ui.screens.pantry
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
|
||||
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_pantry_subtitle
|
||||
import recipe.composeapp.generated.resources.empty_pantry_title
|
||||
import recipe.composeapp.generated.resources.shell_tab_pantry
|
||||
|
||||
/**
|
||||
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
|
||||
* empty body with the inventory list.
|
||||
*
|
||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||
*/
|
||||
@Composable
|
||||
fun PantryScreen(viewModel: PantryViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
|
||||
val today = remember { todayInSystemTz() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
||||
BottomOverlayScaffold(
|
||||
open = horizonState.isCalendarOpen,
|
||||
onDismiss = viewModel.horizon::close,
|
||||
bottomInset = rememberShellChromeHeight(),
|
||||
overlay = {
|
||||
PantryHorizonPill(
|
||||
selectedDate = horizonState.selectedDate,
|
||||
expanded = horizonState.isCalendarOpen,
|
||||
today = today,
|
||||
onExpandedChange = viewModel.horizon::setOpen,
|
||||
onSelectDate = viewModel.horizon::select,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -52,7 +60,7 @@ fun PantryScreen(viewModel: PantryViewModel) {
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyState(
|
||||
icon = BottomBarDestination.Pantry.icon,
|
||||
icon = DockDestination.Pantry.icon,
|
||||
title = stringResource(Res.string.empty_pantry_title),
|
||||
subtitle = stringResource(Res.string.empty_pantry_subtitle),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
package dev.ulfrx.recipe.ui.screens.pantry
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* UI state for [PantryScreen]. Phase 2.1 ships only the empty state. Phase 8
|
||||
* (Pantry) extends this with inventory rows + actions.
|
||||
*/
|
||||
data class PantryState(
|
||||
val isEmpty: Boolean = true,
|
||||
)
|
||||
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
|
||||
|
||||
class PantryViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(PantryState())
|
||||
val state: StateFlow<PantryState> = _state.asStateFlow()
|
||||
val horizon = HorizonCalendarHolder()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.ulfrx.recipe.ui.screens.planner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.ulfrx.recipe.ui.components.calendar.RecipeCalendarPill
|
||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||
import kotlinx.datetime.DatePeriod
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* Planner-screen flavour of [RecipeCalendarPill] — supplies the dummy
|
||||
* "you already have something planned" indicators that will be replaced by
|
||||
* real planner data in Phase 6.
|
||||
*/
|
||||
@Composable
|
||||
fun PlannerCalendarPill(
|
||||
selectedDate: LocalDate,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
onShiftSelection: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val plannedDummy =
|
||||
remember {
|
||||
val today = todayInSystemTz()
|
||||
setOf(today, today.plus(DatePeriod(days = 1)), today.plus(DatePeriod(days = 3)))
|
||||
}
|
||||
|
||||
RecipeCalendarPill(
|
||||
selectedDate = selectedDate,
|
||||
expanded = expanded,
|
||||
onExpandedChange = onExpandedChange,
|
||||
onSelectDate = onSelectDate,
|
||||
onSelectionShift = onShiftSelection,
|
||||
plannedDates = plannedDummy,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -1,197 +1,67 @@
|
||||
package dev.ulfrx.recipe.ui.screens.planner
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
|
||||
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.empty_planner_subtitle
|
||||
import recipe.composeapp.generated.resources.empty_planner_title
|
||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||
|
||||
/**
|
||||
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
|
||||
* empty body with the calendar grid.
|
||||
*
|
||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||
*/
|
||||
@Composable
|
||||
fun PlannerScreen(viewModel: PlannerViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val bgDark = Color(0xFF14181F)
|
||||
val titleColor = Color(0xFFE8E4DC)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(bgDark),
|
||||
BottomOverlayScaffold(
|
||||
open = state.isCalendarOpen,
|
||||
onDismiss = viewModel::closeCalendar,
|
||||
bottomInset = rememberShellChromeHeight(),
|
||||
overlay = {
|
||||
PlannerCalendarPill(
|
||||
selectedDate = state.selectedDate,
|
||||
expanded = state.isCalendarOpen,
|
||||
onExpandedChange = viewModel::setCalendarOpen,
|
||||
onSelectDate = viewModel::selectDate,
|
||||
onShiftSelection = viewModel::shiftSelection,
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Scrollable, visually rich content sitting behind the glass chrome.
|
||||
// Bottom contentPadding extends well past the dock so items keep
|
||||
// scrolling under it (the whole point of this test view).
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = RecipeTheme.spacing.lg,
|
||||
end = RecipeTheme.spacing.lg,
|
||||
top = RecipeTheme.spacing.xl + 48.dp,
|
||||
bottom = 160.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(items = GlassTestItems, key = { it.id }) { item ->
|
||||
GlassTestCard(item = item)
|
||||
}
|
||||
}
|
||||
|
||||
// Title pinned at the top so the chrome glass doesn't have to refract
|
||||
// over the very top of the scrollable list.
|
||||
BasicText(
|
||||
text = stringResource(Res.string.shell_tab_planner),
|
||||
style = RecipeTheme.typography.title.copy(color = titleColor),
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(
|
||||
top = RecipeTheme.spacing.xl,
|
||||
start = RecipeTheme.spacing.lg,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class GlassTestItem(
|
||||
val id: Int,
|
||||
val accent: Color,
|
||||
val cardTone: Color,
|
||||
val titleWeight: Float,
|
||||
val subtitleWeight: Float,
|
||||
)
|
||||
|
||||
private val GlassTestItems: List<GlassTestItem> =
|
||||
run {
|
||||
val accents =
|
||||
listOf(
|
||||
Color(0xFFD97757), // accent terracotta
|
||||
Color(0xFF6EA987), // sage
|
||||
Color(0xFF7A8FB8), // dusty blue
|
||||
Color(0xFFC1864F), // amber
|
||||
Color(0xFFB76E79), // muted rose
|
||||
Color(0xFF6B7A8F), // slate
|
||||
Color(0xFF8E7CC3), // muted violet
|
||||
)
|
||||
val tones =
|
||||
listOf(
|
||||
Color(0xFF1F242C),
|
||||
Color(0xFF232932),
|
||||
Color(0xFF1B2028),
|
||||
Color(0xFF272D36),
|
||||
)
|
||||
List(40) { i ->
|
||||
GlassTestItem(
|
||||
id = i,
|
||||
accent = accents[i % accents.size],
|
||||
cardTone = tones[i % tones.size],
|
||||
titleWeight = 0.80f + ((i * 13) % 20) / 100f,
|
||||
subtitleWeight = 0.55f + ((i * 7) % 40) / 100f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlassTestCard(item: GlassTestItem) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(88.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(item.cardTone),
|
||||
) {
|
||||
// Left accent stripe — varied saturated colors so the dock chrome
|
||||
// gets to refract a clear hue band as you scroll past.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(6.dp)
|
||||
.fillMaxSize()
|
||||
.background(item.accent),
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 12.dp + 6.dp,
|
||||
end = 12.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(top = RecipeTheme.spacing.xl),
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
// Title bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(item.titleWeight)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
|
||||
)
|
||||
// Subtitle bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(item.subtitleWeight)
|
||||
.height(10.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
// Faint metadata dot + bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.18f)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(item.accent.copy(alpha = 0.55f)),
|
||||
BasicText(
|
||||
text = stringResource(Res.string.shell_tab_planner),
|
||||
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
|
||||
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyState(
|
||||
icon = DockDestination.Planner.icon,
|
||||
title = stringResource(Res.string.empty_planner_title),
|
||||
subtitle = stringResource(Res.string.empty_planner_subtitle),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.padding(end = 0.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
package dev.ulfrx.recipe.ui.screens.planner
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* UI state for [PlannerScreen]. Phase 2.1 ships only the empty state. Phase 6
|
||||
* (Meal Planner — Core Write Path) extends this with calendar data + actions.
|
||||
*/
|
||||
data class PlannerState(
|
||||
val isEmpty: Boolean = true,
|
||||
val selectedDate: LocalDate,
|
||||
val isCalendarOpen: Boolean = false,
|
||||
)
|
||||
|
||||
class PlannerViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(PlannerState())
|
||||
private val _state = MutableStateFlow(PlannerState(selectedDate = todayInSystemTz()))
|
||||
val state: StateFlow<PlannerState> = _state.asStateFlow()
|
||||
|
||||
fun selectDate(date: LocalDate) {
|
||||
_state.update { it.copy(selectedDate = date) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the highlighted day without collapsing the calendar pill. Used by
|
||||
* the collapsed strip's week-paged swipe gesture so swipe-to-shift doesn't
|
||||
* also dismiss the calendar.
|
||||
*/
|
||||
fun shiftSelection(date: LocalDate) {
|
||||
_state.update { it.copy(selectedDate = date) }
|
||||
}
|
||||
|
||||
fun setCalendarOpen(open: Boolean) {
|
||||
_state.update { it.copy(isCalendarOpen = open) }
|
||||
}
|
||||
|
||||
fun closeCalendar() {
|
||||
_state.update { it.copy(isCalendarOpen = false) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientCard
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientDivider
|
||||
import dev.ulfrx.recipe.ui.components.recipe.IngredientRow
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeServingsStepper
|
||||
import dev.ulfrx.recipe.ui.components.recipe.NutritionSummary
|
||||
import dev.ulfrx.recipe.ui.components.recipe.scaledBy
|
||||
import dev.ulfrx.recipe.ui.components.section.Section
|
||||
import dev.ulfrx.recipe.ui.components.section.SectionTitle
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.nutrition_label
|
||||
import recipe.composeapp.generated.resources.recipe_detail_section_ingredients
|
||||
import recipe.composeapp.generated.resources.recipe_detail_section_steps
|
||||
import recipe.composeapp.generated.resources.recipe_detail_servings_decrement_a11y
|
||||
import recipe.composeapp.generated.resources.recipe_detail_servings_increment_a11y
|
||||
import recipe.composeapp.generated.resources.recipe_detail_servings_label
|
||||
import recipe.composeapp.generated.resources.recipe_detail_step_number_format
|
||||
|
||||
@Composable
|
||||
internal fun RecipeDetailContent(
|
||||
ready: RecipeDetailState.Ready,
|
||||
onPlanClick: () -> Unit,
|
||||
onServingsChange: (Int) -> Unit,
|
||||
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = RecipeTheme.spacing
|
||||
val scrollState = rememberScrollState()
|
||||
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
val detail = ready.recipe
|
||||
val servings = ready.servings
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
RecipeDetailHero(
|
||||
title = detail.title,
|
||||
cookingMinutes = detail.cookingMinutes,
|
||||
onPlanClick = onPlanClick,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = spacing.lg)) {
|
||||
Spacer(Modifier.height(spacing.xl))
|
||||
NutritionSection(nutrition = detail.nutrition.scaledBy(servings))
|
||||
|
||||
Spacer(Modifier.height(spacing.xl))
|
||||
ServingsSection(servings = servings, onServingsChange = onServingsChange)
|
||||
|
||||
Spacer(Modifier.height(spacing.xl))
|
||||
IngredientsSection(
|
||||
ingredients = detail.ingredients,
|
||||
servings = servings,
|
||||
substitutions = ready.substitutions,
|
||||
onSelectSubstitution = onSelectSubstitution,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(spacing.xl))
|
||||
StepsSection(steps = detail.steps)
|
||||
|
||||
Spacer(Modifier.height(bottomInset + spacing.xxl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NutritionSection(nutrition: RecipeNutritionUi) {
|
||||
Section(title = stringResource(Res.string.nutrition_label)) {
|
||||
NutritionSummary(nutrition = nutrition)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServingsSection(
|
||||
servings: Int,
|
||||
onServingsChange: (Int) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
SectionTitle(text = stringResource(Res.string.recipe_detail_servings_label))
|
||||
RecipeServingsStepper(
|
||||
servings = servings,
|
||||
servingsRange = MIN_RECIPE_SERVINGS..MAX_RECIPE_SERVINGS,
|
||||
decrementContentDescription = stringResource(Res.string.recipe_detail_servings_decrement_a11y),
|
||||
incrementContentDescription = stringResource(Res.string.recipe_detail_servings_increment_a11y),
|
||||
onServingsChange = onServingsChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IngredientsSection(
|
||||
ingredients: List<RecipeIngredientSlotUi>,
|
||||
servings: Int,
|
||||
substitutions: Map<String, String>,
|
||||
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
||||
) {
|
||||
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
|
||||
IngredientCard {
|
||||
ingredients.forEachIndexed { index, slot ->
|
||||
if (index > 0) IngredientDivider()
|
||||
IngredientRow(
|
||||
slot = slot.scaledBy(servings),
|
||||
selectedOptionId = substitutions[slot.id] ?: slot.default.id,
|
||||
onSelect =
|
||||
if (slot.alternatives.isNotEmpty()) {
|
||||
{ choice -> onSelectSubstitution(slot.id, choice.id) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepsSection(steps: List<String>) {
|
||||
Section(title = stringResource(Res.string.recipe_detail_section_steps)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
|
||||
steps.forEachIndexed { index, step ->
|
||||
StepRow(number = index + 1, text = step)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepRow(
|
||||
number: Int,
|
||||
text: String,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm)) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.recipe_detail_step_number_format, number),
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.contentMuted,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = StepNumberTextSize,
|
||||
),
|
||||
modifier = Modifier.width(StepNumberWidth),
|
||||
)
|
||||
BasicText(
|
||||
text = text,
|
||||
style =
|
||||
RecipeTheme.typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = StepTextSize,
|
||||
lineHeight = StepLineHeight,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val StepNumberWidth = 20.dp
|
||||
private val StepNumberTextSize = 11.sp
|
||||
private val StepTextSize = 13.sp
|
||||
private val StepLineHeight = 19.sp
|
||||
@@ -0,0 +1,176 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composables.icons.lucide.Calendar
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.components.button.CircleButton
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.meal_plan_editor_title_a11y
|
||||
import recipe.composeapp.generated.resources.recipe_card_minutes_format
|
||||
import recipe.composeapp.generated.resources.sample_recipe
|
||||
|
||||
@Composable
|
||||
internal fun RecipeDetailHero(
|
||||
title: String,
|
||||
cookingMinutes: Int,
|
||||
onPlanClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val typography = RecipeTheme.typography
|
||||
val spacing = RecipeTheme.spacing
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
top = HERO_TOP_PADDING,
|
||||
bottom = spacing.lg,
|
||||
start = spacing.lg,
|
||||
end = spacing.lg,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.sample_recipe),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(BANNER_ASPECT_RATIO)
|
||||
.shadow(
|
||||
elevation = BANNER_SHADOW_ELEVATION,
|
||||
shape = RoundedCornerShape(BANNER_CORNER),
|
||||
ambientColor = BANNER_SHADOW_COLOR,
|
||||
spotColor = BANNER_SHADOW_COLOR,
|
||||
)
|
||||
.clip(RoundedCornerShape(BANNER_CORNER)),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(spacing.lg))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement =Arrangement.spacedBy(spacing.lg),
|
||||
) {
|
||||
BasicText(
|
||||
text = title,
|
||||
style =
|
||||
typography.display.copy(
|
||||
color = colors.content,
|
||||
fontSize = TITLE_FONT_SIZE,
|
||||
lineHeight = TITLE_LINE_HEIGHT,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Left,
|
||||
),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
MetaChip(
|
||||
icon = Lucide.Clock,
|
||||
text = stringResource(Res.string.recipe_card_minutes_format, cookingMinutes),
|
||||
)
|
||||
}
|
||||
}
|
||||
PlanButton(onClick = onPlanClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaChip(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CHIP_SHAPE)
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.separator, CHIP_SHAPE)
|
||||
.padding(horizontal = CHIP_PADDING_H, vertical = CHIP_PADDING_V),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(CHIP_ICON_GAP),
|
||||
) {
|
||||
UnstyledIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colors.contentMuted,
|
||||
modifier = Modifier.size(CHIP_ICON_SIZE),
|
||||
)
|
||||
BasicText(
|
||||
text = text,
|
||||
style = RecipeTheme.typography.label.copy(color = colors.contentMuted),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanButton(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
CircleButton(
|
||||
onClick = onClick,
|
||||
icon = Lucide.Calendar,
|
||||
contentDescription = stringResource(Res.string.meal_plan_editor_title_a11y),
|
||||
size = PLAN_BUTTON_SIZE,
|
||||
iconSize = PLAN_BUTTON_ICON_SIZE,
|
||||
tint = RecipeTheme.colors.surface,
|
||||
iconTint = RecipeTheme.colors.accent,
|
||||
borderTint = RecipeTheme.colors.borderCard,
|
||||
borderWidth = 1.dp,
|
||||
)
|
||||
}
|
||||
|
||||
private const val BANNER_ASPECT_RATIO = 16f / 9f
|
||||
private val BANNER_CORNER = 20.dp
|
||||
private val BANNER_SHADOW_ELEVATION = 14.dp
|
||||
private val BANNER_SHADOW_COLOR = Color.Black.copy(alpha = 0.45f)
|
||||
|
||||
// Leave room for the sheet handle (8dp top padding + 5dp handle) plus breathing room.
|
||||
private val HERO_TOP_PADDING = 32.dp
|
||||
|
||||
private val TITLE_FONT_SIZE = 19.sp
|
||||
private val TITLE_LINE_HEIGHT = 20.sp
|
||||
|
||||
private val CHIP_SHAPE = RoundedCornerShape(percent = 50)
|
||||
private val CHIP_PADDING_H = 12.dp
|
||||
private val CHIP_PADDING_V = 7.dp
|
||||
private val CHIP_ICON_SIZE = 14.dp
|
||||
private val CHIP_ICON_GAP = 5.dp
|
||||
private val PLAN_BUTTON_SIZE = 50.dp
|
||||
private val PLAN_BUTTON_ICON_SIZE = 25.dp
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.recipe_detail_not_found
|
||||
|
||||
@Composable
|
||||
internal fun RecipeDetailScreen(
|
||||
viewModel: RecipeDetailViewModel,
|
||||
onPlan: (RecipeDetailState.Ready) -> Unit,
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when (val s = state) {
|
||||
is RecipeDetailState.Ready ->
|
||||
RecipeDetailContent(
|
||||
ready = s,
|
||||
onPlanClick = { onPlan(s) },
|
||||
onServingsChange = viewModel::setServings,
|
||||
onSelectSubstitution = viewModel::selectSubstitution,
|
||||
)
|
||||
|
||||
RecipeDetailState.NotFound ->
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.recipe_detail_not_found),
|
||||
style = RecipeTheme.typography.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
|
||||
|
||||
internal const val MIN_RECIPE_SERVINGS = 1
|
||||
internal const val MAX_RECIPE_SERVINGS = 12
|
||||
|
||||
sealed interface RecipeDetailState {
|
||||
data object NotFound : RecipeDetailState
|
||||
|
||||
data class Ready(
|
||||
val recipe: RecipeUi,
|
||||
val servings: Int = MIN_RECIPE_SERVINGS,
|
||||
val substitutions: Map<String, String> = emptyMap(),
|
||||
) : RecipeDetailState
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.ulfrx.recipe.ui.components.recipe.options
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class RecipeDetailViewModel(recipeId: String) : ViewModel() {
|
||||
private val _state = MutableStateFlow(sampleRecipe(recipeId)?.let(RecipeDetailState::Ready) ?: RecipeDetailState.NotFound)
|
||||
val state: StateFlow<RecipeDetailState> = _state.asStateFlow()
|
||||
|
||||
fun setServings(value: Int) =
|
||||
_state.update { current ->
|
||||
if (current is RecipeDetailState.Ready) {
|
||||
current.copy(servings = value.coerceIn(MIN_RECIPE_SERVINGS, MAX_RECIPE_SERVINGS))
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSubstitution(
|
||||
slotId: String,
|
||||
optionId: String,
|
||||
) = _state.update { current ->
|
||||
if (current !is RecipeDetailState.Ready) return@update current
|
||||
val slot = current.recipe.ingredients.firstOrNull { it.id == slotId } ?: return@update current
|
||||
if (slot.options.none { it.id == optionId }) return@update current
|
||||
|
||||
val substitutions =
|
||||
if (optionId == slot.default.id) {
|
||||
current.substitutions - slotId
|
||||
} else {
|
||||
current.substitutions + (slotId to optionId)
|
||||
}
|
||||
current.copy(substitutions = substitutions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipedetail
|
||||
|
||||
import dev.ulfrx.recipe.ui.components.recipe.MealSlot
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientOptionUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeIngredientSlotUi
|
||||
import dev.ulfrx.recipe.ui.components.recipe.RecipeNutritionUi
|
||||
|
||||
private val LunchOrDinner = listOf(MealSlot.Lunch, MealSlot.Dinner)
|
||||
private val BreakfastOrSnack = listOf(MealSlot.Breakfast, MealSlot.Snack)
|
||||
private val LightMeal = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper)
|
||||
|
||||
internal val sampleRecipes: Map<String, RecipeUi> =
|
||||
listOf(
|
||||
RecipeUi(
|
||||
id = "rcp_nalesniki",
|
||||
title = "Naleśniki z twarogiem",
|
||||
cookingMinutes = 25,
|
||||
nutrition = RecipeNutritionUi(kcal = 320, protein = 18, fat = 9, carbs = 42),
|
||||
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Mąka pszenna", 60.0, "g"),
|
||||
slot("Mleko", 125.0, "ml"),
|
||||
slot("Jajka", 1.0, "szt."),
|
||||
slot(
|
||||
"Twaróg półtłusty",
|
||||
100.0,
|
||||
"g",
|
||||
alt("Twaróg chudy", 100.0, "g"),
|
||||
alt("Serek wiejski", 120.0, "g"),
|
||||
),
|
||||
slot("Miód", 10.0, "g", alt("Syrop klonowy", 12.0, "g")),
|
||||
slot("Olej do smażenia", 5.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Zmiksuj mąkę, mleko, jajko i szczyptę soli na gładkie ciasto. Odstaw na 10 minut.",
|
||||
"Rozgrzej odrobinę oleju na patelni i smaż cienkie naleśniki z obu stron na złoto.",
|
||||
"Twaróg rozetrzyj z miodem na gładką masę.",
|
||||
"Nałóż masę twarogową na naleśniki, zwiń i podawaj.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_owsianka",
|
||||
title = "Owsianka z owocami i orzechami",
|
||||
cookingMinutes = 10,
|
||||
nutrition = RecipeNutritionUi(kcal = 280, protein = 9, fat = 11, carbs = 38),
|
||||
allowedSlots = BreakfastOrSnack,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Płatki owsiane", 50.0, "g"),
|
||||
slot(
|
||||
"Mleko",
|
||||
200.0,
|
||||
"ml",
|
||||
alt("Napój owsiany", 200.0, "ml"),
|
||||
alt("Napój migdałowy", 200.0, "ml"),
|
||||
),
|
||||
slot("Banan", 0.5, "szt."),
|
||||
slot(
|
||||
"Borówki",
|
||||
40.0,
|
||||
"g",
|
||||
alt("Maliny", 40.0, "g"),
|
||||
alt("Truskawki", 50.0, "g"),
|
||||
),
|
||||
slot(
|
||||
"Orzechy włoskie",
|
||||
15.0,
|
||||
"g",
|
||||
alt("Migdały", 15.0, "g"),
|
||||
alt("Orzechy laskowe", 15.0, "g"),
|
||||
),
|
||||
slot("Miód", 10.0, "g"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Płatki owsiane zalej mlekiem i gotuj na małym ogniu 4–5 minut, mieszając.",
|
||||
"Przełóż owsiankę do miski.",
|
||||
"Ułóż na wierzchu pokrojonego banana, borówki i posiekane orzechy.",
|
||||
"Polej miodem i podawaj.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_spaghetti",
|
||||
title = "Spaghetti bolognese",
|
||||
cookingMinutes = 40,
|
||||
nutrition = RecipeNutritionUi(kcal = 540, protein = 28, fat = 18, carbs = 65),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Makaron spaghetti", 100.0, "g"),
|
||||
slot(
|
||||
"Mięso mielone wołowe",
|
||||
120.0,
|
||||
"g",
|
||||
alt("Mięso mielone z indyka", 120.0, "g"),
|
||||
alt("Soczewica czerwona", 60.0, "g"),
|
||||
),
|
||||
slot("Passata pomidorowa", 150.0, "ml"),
|
||||
slot("Cebula", 0.5, "szt."),
|
||||
slot("Czosnek", 1.0, "ząbek"),
|
||||
slot("Oliwa", 10.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Makaron ugotuj al dente w osolonej wodzie wg opakowania.",
|
||||
"Na oliwie zeszklij posiekaną cebulę i czosnek, dodaj mięso i smaż do zrumienienia.",
|
||||
"Wlej passatę, dopraw solą, pieprzem i ziołami. Duś 15 minut.",
|
||||
"Wymieszaj sos z odsączonym makaronem i podawaj.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_pierogi",
|
||||
title = "Pierogi ruskie",
|
||||
cookingMinutes = 90,
|
||||
nutrition = RecipeNutritionUi(kcal = 460, protein = 14, fat = 14, carbs = 68),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Mąka pszenna", 120.0, "g"),
|
||||
slot("Woda", 60.0, "ml"),
|
||||
slot("Ziemniaki", 150.0, "g"),
|
||||
slot("Twaróg półtłusty", 80.0, "g"),
|
||||
slot("Cebula", 0.5, "szt."),
|
||||
slot("Masło", 10.0, "g"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Z mąki, ciepłej wody i szczypty soli zagnieć gładkie ciasto. Odstaw pod ściereczką.",
|
||||
"Ziemniaki ugotuj i ugnieć z twarogiem. Dodaj zeszkloną cebulę, dopraw solą i pieprzem.",
|
||||
"Rozwałkuj ciasto, wykrawaj krążki, nakładaj farsz i zlepiaj pierogi.",
|
||||
"Gotuj partiami w osolonej wodzie 3–4 minuty od wypłynięcia.",
|
||||
"Podawaj okraszone masłem i podsmażoną cebulą.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_kanapka_awokado",
|
||||
title = "Kanapka z awokado i jajkiem",
|
||||
cookingMinutes = 5,
|
||||
nutrition = RecipeNutritionUi(kcal = 210, protein = 9, fat = 13, carbs = 16),
|
||||
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Lunch, MealSlot.Supper, MealSlot.Snack),
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Pieczywo razowe", 1.0, "kromka"),
|
||||
slot("Awokado", 0.5, "szt."),
|
||||
slot("Jajko", 1.0, "szt."),
|
||||
slot("Sok z cytryny", 5.0, "ml"),
|
||||
slot("Szczypiorek", 5.0, "g"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Jajko ugotuj na twardo (ok. 9 minut), ostudź i obierz.",
|
||||
"Awokado rozgnieć widelcem z sokiem z cytryny, solą i pieprzem.",
|
||||
"Posmaruj kromkę pastą z awokado.",
|
||||
"Ułóż plastry jajka i posyp szczypiorkiem.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_schabowy",
|
||||
title = "Schabowy z ziemniakami",
|
||||
cookingMinutes = 60,
|
||||
nutrition = RecipeNutritionUi(kcal = 720, protein = 38, fat = 34, carbs = 62),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Schab", 150.0, "g"),
|
||||
slot("Jajko", 1.0, "szt."),
|
||||
slot("Bułka tarta", 40.0, "g"),
|
||||
slot("Mąka pszenna", 20.0, "g"),
|
||||
slot("Ziemniaki", 300.0, "g"),
|
||||
slot("Olej do smażenia", 30.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Ziemniaki obierz i ugotuj w osolonej wodzie.",
|
||||
"Schab rozbij na cienkie kotlety, dopraw solą i pieprzem.",
|
||||
"Panieruj kolejno w mące, rozkłóconym jajku i bułce tartej.",
|
||||
"Smaż na rozgrzanym oleju z obu stron na złoto.",
|
||||
"Podawaj z ziemniakami i ulubioną surówką.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_salatka_grecka",
|
||||
title = "Sałatka grecka",
|
||||
cookingMinutes = 15,
|
||||
nutrition = RecipeNutritionUi(kcal = 310, protein = 9, fat = 26, carbs = 12),
|
||||
allowedSlots = LightMeal,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Pomidory", 150.0, "g"),
|
||||
slot("Ogórek", 0.5, "szt."),
|
||||
slot("Papryka czerwona", 0.5, "szt."),
|
||||
slot("Ser feta", 60.0, "g", alt("Ser sałatkowy", 60.0, "g")),
|
||||
slot("Oliwki czarne", 30.0, "g"),
|
||||
slot("Oliwa", 15.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Pomidory, ogórka i paprykę pokrój w grubą kostkę.",
|
||||
"Przełóż warzywa do miski, dodaj oliwki.",
|
||||
"Pokrusz fetę na wierzch.",
|
||||
"Skrop oliwą, dopraw oregano i pieprzem. Delikatnie wymieszaj.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_pomidorowa",
|
||||
title = "Zupa pomidorowa z ryżem",
|
||||
cookingMinutes = 35,
|
||||
nutrition = RecipeNutritionUi(kcal = 240, protein = 7, fat = 6, carbs = 39),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Passata pomidorowa", 200.0, "ml"),
|
||||
slot("Bulion warzywny", 400.0, "ml"),
|
||||
slot("Ryż", 40.0, "g"),
|
||||
slot("Marchewka", 1.0, "szt."),
|
||||
slot("Śmietana 18%", 20.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Ryż ugotuj osobno do miękkości.",
|
||||
"W garnku zagotuj bulion ze startą marchewką, gotuj 10 minut.",
|
||||
"Wlej passatę i gotuj kolejne 10 minut. Dopraw solą i cukrem.",
|
||||
"Zahartuj śmietanę i wmieszaj do zupy. Podawaj z ryżem.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_kurczak_curry",
|
||||
title = "Kurczak curry z ryżem basmati",
|
||||
cookingMinutes = 45,
|
||||
nutrition = RecipeNutritionUi(kcal = 580, protein = 34, fat = 18, carbs = 70),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Pierś z kurczaka", 150.0, "g"),
|
||||
slot("Ryż basmati", 80.0, "g"),
|
||||
slot("Mleko kokosowe", 120.0, "ml", alt("Śmietanka 18%", 120.0, "ml")),
|
||||
slot("Pasta curry", 20.0, "g"),
|
||||
slot("Cebula", 0.5, "szt."),
|
||||
slot("Olej", 10.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Ryż basmati ugotuj wg opakowania.",
|
||||
"Kurczaka pokrój w kostkę i obsmaż na oleju z posiekaną cebulą.",
|
||||
"Dodaj pastę curry, smaż minutę, wlej mleko kokosowe.",
|
||||
"Duś 12–15 minut do zgęstnienia sosu. Dopraw solą.",
|
||||
"Podawaj z ryżem basmati.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_jajecznica",
|
||||
title = "Jajecznica na maśle ze szczypiorkiem",
|
||||
cookingMinutes = 8,
|
||||
nutrition = RecipeNutritionUi(kcal = 290, protein = 19, fat = 22, carbs = 3),
|
||||
allowedSlots = listOf(MealSlot.Breakfast, MealSlot.Supper, MealSlot.Snack),
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Jajka", 3.0, "szt."),
|
||||
slot("Masło", 10.0, "g"),
|
||||
slot("Szczypiorek", 10.0, "g"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Rozpuść masło na patelni na małym ogniu.",
|
||||
"Wbij jajka i smaż, delikatnie mieszając, do ścięcia.",
|
||||
"Dopraw solą i pieprzem.",
|
||||
"Posyp posiekanym szczypiorkiem i podawaj.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_risotto",
|
||||
title = "Risotto z grzybami leśnymi",
|
||||
cookingMinutes = 35,
|
||||
nutrition = RecipeNutritionUi(kcal = 470, protein = 12, fat = 16, carbs = 66),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Ryż arborio", 80.0, "g"),
|
||||
slot("Grzyby leśne", 100.0, "g"),
|
||||
slot("Bulion warzywny", 350.0, "ml"),
|
||||
slot("Cebula", 0.5, "szt."),
|
||||
slot("Parmezan", 20.0, "g"),
|
||||
slot("Masło", 15.0, "g"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Na maśle zeszklij posiekaną cebulę, dodaj grzyby i podsmaż.",
|
||||
"Wsyp ryż i smaż minutę, aż stanie się szklisty.",
|
||||
"Dolewaj ciepły bulion po chochli, mieszając, aż ryż go wchłonie.",
|
||||
"Gdy ryż jest al dente, wmieszaj parmezan i masło. Dopraw i podawaj.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_tortilla",
|
||||
title = "Tortilla z kurczakiem i warzywami",
|
||||
cookingMinutes = 20,
|
||||
nutrition = RecipeNutritionUi(kcal = 430, protein = 26, fat = 14, carbs = 48),
|
||||
allowedSlots = listOf(MealSlot.Lunch, MealSlot.Dinner, MealSlot.Supper),
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Tortilla pszenna", 1.0, "szt."),
|
||||
slot("Pierś z kurczaka", 120.0, "g"),
|
||||
slot("Papryka", 0.5, "szt."),
|
||||
slot("Sałata", 30.0, "g"),
|
||||
slot("Sos jogurtowy", 30.0, "g"),
|
||||
slot("Olej", 5.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Kurczaka pokrój w paski, dopraw i obsmaż na oleju.",
|
||||
"Paprykę pokrój w cienkie paski.",
|
||||
"Tortillę podgrzej na suchej patelni.",
|
||||
"Wyłóż sałatę, kurczaka i paprykę, polej sosem i zawiń.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_smoothie",
|
||||
title = "Smoothie bananowo-szpinakowe",
|
||||
cookingMinutes = 5,
|
||||
nutrition = RecipeNutritionUi(kcal = 180, protein = 6, fat = 3, carbs = 33),
|
||||
allowedSlots = BreakfastOrSnack,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Banan", 1.0, "szt."),
|
||||
slot("Szpinak świeży", 30.0, "g"),
|
||||
slot(
|
||||
"Jogurt naturalny",
|
||||
100.0,
|
||||
"g",
|
||||
alt("Skyr", 100.0, "g"),
|
||||
alt("Kefir", 120.0, "g"),
|
||||
),
|
||||
slot("Mleko", 100.0, "ml", alt("Napój owsiany", 100.0, "ml")),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Wszystkie składniki umieść w blenderze.",
|
||||
"Miksuj do uzyskania gładkiej konsystencji.",
|
||||
"Przelej do szklanki i podawaj od razu.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_losos",
|
||||
title = "Łosoś pieczony z brokułami",
|
||||
cookingMinutes = 30,
|
||||
nutrition = RecipeNutritionUi(kcal = 510, protein = 38, fat = 32, carbs = 12),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Filet z łososia", 150.0, "g"),
|
||||
slot("Brokuł", 200.0, "g"),
|
||||
slot("Oliwa", 15.0, "ml"),
|
||||
slot("Cytryna", 0.5, "szt."),
|
||||
slot("Czosnek", 1.0, "ząbek"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Piekarnik nagrzej do 200°C.",
|
||||
"Łososia skrop oliwą i sokiem z cytryny, dopraw solą i pieprzem.",
|
||||
"Brokuł podziel na różyczki, wymieszaj z oliwą i czosnkiem.",
|
||||
"Piecz łososia i brokuły na blasze ok. 15–18 minut.",
|
||||
),
|
||||
),
|
||||
RecipeUi(
|
||||
id = "rcp_nadziewane_papryki",
|
||||
title = "Papryki nadziewane kaszą i warzywami",
|
||||
cookingMinutes = 55,
|
||||
nutrition = RecipeNutritionUi(kcal = 390, protein = 11, fat = 12, carbs = 58),
|
||||
allowedSlots = LunchOrDinner,
|
||||
ingredients =
|
||||
listOf(
|
||||
slot("Papryka", 2.0, "szt."),
|
||||
slot("Kasza jaglana", 60.0, "g"),
|
||||
slot("Cukinia", 80.0, "g"),
|
||||
slot("Passata pomidorowa", 100.0, "ml"),
|
||||
slot("Cebula", 0.5, "szt."),
|
||||
slot("Oliwa", 10.0, "ml"),
|
||||
),
|
||||
steps =
|
||||
listOf(
|
||||
"Kaszę jaglaną ugotuj do miękkości.",
|
||||
"Na oliwie podsmaż cebulę i pokrojoną cukinię.",
|
||||
"Wymieszaj kaszę z warzywami i połową passaty. Dopraw.",
|
||||
"Papryki przekrój, oczyść i napełnij farszem.",
|
||||
"Polej resztą passaty i piecz w 190°C ok. 30 minut.",
|
||||
),
|
||||
),
|
||||
).associateBy { it.id }
|
||||
|
||||
internal fun sampleRecipe(id: String): RecipeUi? = sampleRecipes[id]
|
||||
|
||||
private fun slot(
|
||||
name: String,
|
||||
amount: Double,
|
||||
unit: String,
|
||||
vararg alternatives: RecipeIngredientOptionUi,
|
||||
) = RecipeIngredientSlotUi(
|
||||
default = option(name, amount, unit),
|
||||
alternatives = alternatives.toList(),
|
||||
id = "sample-slot:$name:$amount:$unit",
|
||||
)
|
||||
|
||||
private fun option(
|
||||
name: String,
|
||||
amount: Double,
|
||||
unit: String,
|
||||
) = RecipeIngredientOptionUi(
|
||||
id = "sample:$name",
|
||||
name = name,
|
||||
amount = amount,
|
||||
unit = unit,
|
||||
)
|
||||
|
||||
private fun alt(
|
||||
name: String,
|
||||
amount: Double,
|
||||
unit: String,
|
||||
) = option(name, amount, unit)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dev.ulfrx.recipe.ui.screens.recipesheet
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.ulfrx.recipe.navigation.MealPlanEditorSource
|
||||
import dev.ulfrx.recipe.navigation.Screen
|
||||
import dev.ulfrx.recipe.ui.components.sheet.RecipeBottomSheet
|
||||
import dev.ulfrx.recipe.ui.components.sheet.RecipeBottomSheetState
|
||||
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorScreen
|
||||
import dev.ulfrx.recipe.ui.screens.mealplaneditor.MealPlanEditorViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailScreen
|
||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun RecipeSheet(state: RecipeBottomSheetState<Screen>) {
|
||||
RecipeBottomSheet(state = state) {
|
||||
entry<Screen.RecipeDetail> { key ->
|
||||
val vm: RecipeDetailViewModel = koinViewModel { parametersOf(key.recipeId) }
|
||||
|
||||
RecipeDetailScreen(
|
||||
viewModel = vm,
|
||||
onPlan = { ready ->
|
||||
state.push(
|
||||
Screen.MealPlanEditor.Open(
|
||||
MealPlanEditorSource.NewFromRecipe(
|
||||
recipeId = ready.recipe.id,
|
||||
initialServings = ready.servings,
|
||||
initialSubstitutions = ready.substitutions,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<Screen.MealPlanEditor.Open> { key ->
|
||||
val vm: MealPlanEditorViewModel = koinViewModel { parametersOf(key.source) }
|
||||
|
||||
MealPlanEditorScreen(
|
||||
viewModel = vm,
|
||||
onBack = state::pop,
|
||||
onConfirm = { _ ->
|
||||
// TODO Phase 6: persist via PlannedMealsRepository
|
||||
state.dismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,49 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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 com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Search
|
||||
import dev.ulfrx.recipe.navigation.Screen
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.components.sheet.rememberRecipeBottomSheetState
|
||||
import dev.ulfrx.recipe.ui.screens.recipesheet.RecipeSheet
|
||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
|
||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.search_screen_curated_subtitle
|
||||
import recipe.composeapp.generated.resources.search_screen_curated_title
|
||||
import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle
|
||||
import recipe.composeapp.generated.resources.search_screen_empty_results_title
|
||||
|
||||
/**
|
||||
* Global search destination — overlays the active tab when
|
||||
* [ShellSearchViewModel.state.isOpen] is true. Hosted by `AppShell`, not by the
|
||||
* tab `NavDisplay`, so navigating in/out doesn't disturb per-tab back stacks.
|
||||
*
|
||||
* Two body modes driven by `state.isFocused`:
|
||||
* - **B (unfocused)** — curated landing. v1 placeholder copy; later phases will
|
||||
* surface recents, quick filters, and per-tab shortcuts here.
|
||||
* - **C (focused)** — live search. v1 shows an empty-results hint until per-
|
||||
* feature SearchSources are wired in Phase 5/6/8/9.
|
||||
*
|
||||
* The search input pill itself lives in the bottom chrome (see `SearchChrome.kt`),
|
||||
* not on this screen — keeping the keyboard-adjacent affordance consistent with
|
||||
* the rest of the shell.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchScreen(viewModel: ShellSearchViewModel) {
|
||||
fun SearchScreen(
|
||||
viewModel: ShellSearchViewModel,
|
||||
catalogGridState: LazyGridState,
|
||||
) {
|
||||
val catalogViewModel: RecipeCatalogViewModel = koinViewModel()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val bgDark = Color(0xFF14181F)
|
||||
val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
|
||||
val bottomSheetState = rememberRecipeBottomSheetState<Screen>()
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (state.isFocused) bgDark else RecipeTheme.colors.background),
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
if (state.isFocused) {
|
||||
// Sample search-result list — visual aid so the search pill / dock
|
||||
// chrome has scrollable content underneath while wiring up real
|
||||
// SearchSources lands in later phases. Remove once Phase 5/6/8/9
|
||||
// back this screen with real results.
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = RecipeTheme.spacing.lg,
|
||||
end = RecipeTheme.spacing.lg,
|
||||
top = RecipeTheme.spacing.xl,
|
||||
bottom = 160.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(items = SearchResultSamples, key = { it.id }) { item ->
|
||||
SearchResultRow(item = item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -98,110 +53,19 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
|
||||
) {
|
||||
EmptyState(
|
||||
icon = Lucide.Search,
|
||||
title = stringResource(Res.string.search_screen_curated_title),
|
||||
subtitle = stringResource(Res.string.search_screen_curated_subtitle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SearchResultSample(
|
||||
val id: Int,
|
||||
val avatarColor: Color,
|
||||
val cardTone: Color,
|
||||
val titleWeight: Float,
|
||||
val subtitleWeight: Float,
|
||||
val tagWeight: Float,
|
||||
)
|
||||
|
||||
private val SearchResultSamples: List<SearchResultSample> =
|
||||
run {
|
||||
val avatars =
|
||||
listOf(
|
||||
Color(0xFFD97757), // terracotta
|
||||
Color(0xFF6EA987), // sage
|
||||
Color(0xFF7A8FB8), // dusty blue
|
||||
Color(0xFFC1864F), // amber
|
||||
Color(0xFFB76E79), // muted rose
|
||||
Color(0xFF6B7A8F), // slate
|
||||
Color(0xFF8E7CC3), // muted violet
|
||||
Color(0xFFA89B7C), // olive
|
||||
)
|
||||
val tones =
|
||||
listOf(
|
||||
Color(0xFF1F242C),
|
||||
Color(0xFF232932),
|
||||
Color(0xFF1B2028),
|
||||
Color(0xFF272D36),
|
||||
)
|
||||
List(36) { i ->
|
||||
SearchResultSample(
|
||||
id = i,
|
||||
avatarColor = avatars[i % avatars.size],
|
||||
cardTone = tones[i % tones.size],
|
||||
titleWeight = 0.62f + ((i * 11) % 30) / 100f,
|
||||
subtitleWeight = 0.40f + ((i * 7) % 35) / 100f,
|
||||
tagWeight = 0.12f + ((i * 5) % 14) / 100f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(item: SearchResultSample) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(76.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(item.cardTone)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Round avatar / thumbnail slot — gives each row a recognizable
|
||||
// colored anchor that refracts cleanly through the search pill above.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(item.avatarColor),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxHeight().padding(vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
) {
|
||||
// Title bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(item.titleWeight)
|
||||
.height(13.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
|
||||
)
|
||||
// Subtitle bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(item.subtitleWeight)
|
||||
.height(9.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Small accent tag pill
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(item.tagWeight)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(item.avatarColor.copy(alpha = 0.65f)),
|
||||
title = stringResource(Res.string.search_screen_empty_results_title),
|
||||
subtitle = stringResource(Res.string.search_screen_empty_results_subtitle),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RecipeCatalogGrid(
|
||||
state = catalogState,
|
||||
onRecipeClick = { bottomSheetState.open(Screen.RecipeDetail(it)) },
|
||||
gridState = catalogGridState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
RecipeSheet(state = bottomSheetState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchState
|
||||
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search.catalog
|
||||
|
||||
data class RecipeCardUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val cookingMinutes: Int,
|
||||
val kcal: Int,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search.catalog
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Flame
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.recipe_card_kcal_format
|
||||
import recipe.composeapp.generated.resources.recipe_card_minutes_format
|
||||
import recipe.composeapp.generated.resources.sample_recipe
|
||||
|
||||
@Composable
|
||||
fun RecipeCatalogGrid(
|
||||
state: RecipeCatalogState,
|
||||
onRecipeClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
gridState: LazyGridState = rememberLazyGridState(),
|
||||
) {
|
||||
val spacing = RecipeTheme.spacing
|
||||
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = MinRecipeCardWidth),
|
||||
state = gridState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = spacing.lg,
|
||||
end = spacing.lg,
|
||||
top = statusBarTop + spacing.lg,
|
||||
bottom = GridBottomPadding,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
items(state.cards, key = { it.id }) { card ->
|
||||
RecipeCard(card = card, onClick = { onRecipeClick(card.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipeCard(
|
||||
card: RecipeCardUi,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
val typography = RecipeTheme.typography
|
||||
val cardShape = RoundedCornerShape(CardCornerRadius)
|
||||
|
||||
UnstyledButton(
|
||||
onClick = onClick,
|
||||
backgroundColor = colors.surface,
|
||||
contentColor = colors.content,
|
||||
shape = cardShape,
|
||||
borderColor = Color.Transparent,
|
||||
borderWidth = 0.dp,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CardHeight)
|
||||
.shadow(elevation = CardElevation, shape = cardShape, clip = false)
|
||||
.clip(cardShape),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
RecipeThumbnail()
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(CardContentPadding),
|
||||
) {
|
||||
BasicText(
|
||||
text = card.title,
|
||||
style =
|
||||
typography.body.copy(
|
||||
color = colors.content,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = CardTitleTextSize,
|
||||
lineHeight = CardTitleLineHeight,
|
||||
),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
RecipeMetaRow(card = card)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipeThumbnail() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ThumbnailHeight),
|
||||
) {
|
||||
ThumbnailPlaceholder()
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.sample_recipe),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThumbnailPlaceholder() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(RecipeTheme.colors.surfaceGlass),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipeMetaRow(card: RecipeCardUi) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MetaItem(
|
||||
icon = Lucide.Clock,
|
||||
label = stringResource(Res.string.recipe_card_minutes_format, card.cookingMinutes),
|
||||
)
|
||||
MetaItem(
|
||||
icon = Lucide.Flame,
|
||||
label = stringResource(Res.string.recipe_card_kcal_format, card.kcal),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
) {
|
||||
val colors = RecipeTheme.colors
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
|
||||
) {
|
||||
UnstyledIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colors.contentMuted,
|
||||
modifier = Modifier.size(MetaIconSize),
|
||||
)
|
||||
BasicText(
|
||||
text = label,
|
||||
style =
|
||||
RecipeTheme.typography.label.copy(
|
||||
color = colors.contentMuted,
|
||||
fontSize = CardMetaTextSize,
|
||||
lineHeight = CardMetaLineHeight,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val MinRecipeCardWidth = 104.dp
|
||||
private val GridBottomPadding = 96.dp
|
||||
private val CardHeight = 190.dp
|
||||
private val ThumbnailHeight = 87.dp
|
||||
private val CardCornerRadius = 17.dp
|
||||
private val CardContentPadding = 10.dp
|
||||
private val CardElevation = 3.dp
|
||||
private val MetaIconSize = 11.dp
|
||||
private val CardTitleTextSize = 11.sp
|
||||
private val CardTitleLineHeight = 14.sp
|
||||
private val CardMetaTextSize = 10.sp
|
||||
private val CardMetaLineHeight = 13.sp
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search.catalog
|
||||
|
||||
data class RecipeCatalogState(
|
||||
val cards: List<RecipeCardUi> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search.catalog
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class RecipeCatalogViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(RecipeCatalogState(cards = sampleRecipeCatalogCards))
|
||||
val state: StateFlow<RecipeCatalogState> = _state.asStateFlow()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search.catalog
|
||||
|
||||
internal val sampleRecipeCatalogCards: List<RecipeCardUi> =
|
||||
listOf(
|
||||
RecipeCardUi(
|
||||
id = "rcp_nalesniki",
|
||||
title = "Naleśniki z twarogiem",
|
||||
cookingMinutes = 25,
|
||||
kcal = 320,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_owsianka",
|
||||
title = "Owsianka z owocami i orzechami",
|
||||
cookingMinutes = 10,
|
||||
kcal = 280,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_spaghetti",
|
||||
title = "Spaghetti bolognese",
|
||||
cookingMinutes = 40,
|
||||
kcal = 540,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_pierogi",
|
||||
title = "Pierogi ruskie",
|
||||
cookingMinutes = 90,
|
||||
kcal = 460,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_kanapka_awokado",
|
||||
title = "Kanapka z awokado i jajkiem",
|
||||
cookingMinutes = 5,
|
||||
kcal = 210,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_schabowy",
|
||||
title = "Schabowy z ziemniakami",
|
||||
cookingMinutes = 60,
|
||||
kcal = 720,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_salatka_grecka",
|
||||
title = "Sałatka grecka",
|
||||
cookingMinutes = 15,
|
||||
kcal = 310,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_pomidorowa",
|
||||
title = "Zupa pomidorowa z ryżem",
|
||||
cookingMinutes = 35,
|
||||
kcal = 240,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_kurczak_curry",
|
||||
title = "Kurczak curry z ryżem basmati",
|
||||
cookingMinutes = 45,
|
||||
kcal = 580,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_jajecznica",
|
||||
title = "Jajecznica na maśle ze szczypiorkiem",
|
||||
cookingMinutes = 8,
|
||||
kcal = 290,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_risotto",
|
||||
title = "Risotto z grzybami leśnymi",
|
||||
cookingMinutes = 35,
|
||||
kcal = 470,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_tortilla",
|
||||
title = "Tortilla z kurczakiem i warzywami",
|
||||
cookingMinutes = 20,
|
||||
kcal = 430,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_smoothie",
|
||||
title = "Smoothie bananowo-szpinakowe",
|
||||
cookingMinutes = 5,
|
||||
kcal = 180,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_losos",
|
||||
title = "Łosoś pieczony z brokułami",
|
||||
cookingMinutes = 30,
|
||||
kcal = 510,
|
||||
),
|
||||
RecipeCardUi(
|
||||
id = "rcp_nadziewane_papryki",
|
||||
title = "Papryki nadziewane kaszą i warzywami",
|
||||
cookingMinutes = 55,
|
||||
kcal = 390,
|
||||
),
|
||||
)
|
||||
@@ -1,103 +1,55 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shell
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.backhandler.BackHandler
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
||||
import dev.ulfrx.recipe.navigation.TabNavigator
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
||||
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
|
||||
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
|
||||
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.search_placeholder
|
||||
|
||||
/**
|
||||
* Authenticated root composable. Owns:
|
||||
* - the per-tab navigation back stacks via [TabNavigator]
|
||||
* - the shell-wide search affordance via [ShellSearchViewModel]
|
||||
*
|
||||
* ## Body modes (driven by `searchVm.state.isOpen`)
|
||||
*
|
||||
* - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom
|
||||
* chrome is `[DockBar (full)] [FloatingSearchButton]`.
|
||||
* - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom
|
||||
* chrome is [SearchPillRow], whose layout shifts further on `isFocused`
|
||||
* (collapsed dock icon yields to a full-width pill + X — see SearchChrome.kt).
|
||||
*
|
||||
* ## Back-press handling
|
||||
*
|
||||
* While search is open, a [BackHandler] consumes the back press as a no-op:
|
||||
* the user must exit search explicitly via the collapsed dock icon (B→A) or X
|
||||
* (C→B). Confirmed product decision — no implicit dismissal while in search.
|
||||
*
|
||||
* ## Why TabNavigator and not the AndroidX NavController
|
||||
* (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See
|
||||
* [RootNavDisplay] for the full rationale.)
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the
|
||||
// latter is overkill for a static "consume back" guard. Revisit when stable.
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppShell(modifier: Modifier = Modifier) {
|
||||
val navigator = remember { TabNavigator() }
|
||||
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||
val catalogGridState = rememberLazyGridState()
|
||||
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 overlayDismisser = remember { OverlayDismisser() }
|
||||
|
||||
BackHandler(enabled = searchState.isOpen) {
|
||||
// Blocked — user must exit search via explicit affordance (dock icon or X).
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
||||
CompositionLocalProvider(
|
||||
LocalGlassBackdropState provides backdropState,
|
||||
LocalOverlayDismisser provides overlayDismisser,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
// Body — cross-fade between the tab stack and the search overlay.
|
||||
GlassBackdropSource(
|
||||
state = backdropState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -112,7 +64,10 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
label = "AppShell body",
|
||||
) { searchOpen ->
|
||||
if (searchOpen) {
|
||||
SearchScreen(viewModel = searchVm)
|
||||
SearchScreen(
|
||||
viewModel = searchVm,
|
||||
catalogGridState = catalogGridState,
|
||||
)
|
||||
} else {
|
||||
RootNavDisplay(
|
||||
navigator = navigator,
|
||||
@@ -122,115 +77,23 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom chrome — Apple-Music-style: don't respect the full nav-bar
|
||||
// inset (home indicator) for the bottom edge; halve it so chrome sits
|
||||
// close to the bottom and the home indicator visually overlaps the
|
||||
// chrome substrate. When IME is up, use the full IME inset (it's much
|
||||
// larger than navInset/2, so `max` keeps the chrome above the keyboard).
|
||||
val bottomInset =
|
||||
with(LocalDensity.current) {
|
||||
val imePx = WindowInsets.ime.getBottom(this)
|
||||
val navPx = WindowInsets.navigationBars.getBottom(this)
|
||||
maxOf(imePx, navPx / 2).toDp()
|
||||
}
|
||||
// Horizontal chrome padding animates with the search state:
|
||||
// - Closed (dock visible) → xl (24 dp)
|
||||
// - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly
|
||||
// inset from the dock's footprint
|
||||
// - Open, focused (search C) → 8 dp, so the input reads as a width
|
||||
// extension of the keyboard above it
|
||||
val horizontalPadding by animateDpAsState(
|
||||
targetValue =
|
||||
when {
|
||||
!searchState.isOpen -> RecipeTheme.spacing.xl
|
||||
!searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp
|
||||
else -> 8.dp
|
||||
},
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||
label = "chrome horizontal padding",
|
||||
ShellBottomChrome(
|
||||
activeTab = navigator.activeTab,
|
||||
onTabSelect = { tab ->
|
||||
overlayDismisser.dismissAll()
|
||||
navigator.selectTab(tab)
|
||||
},
|
||||
search =
|
||||
SearchHandlers(
|
||||
state = searchState,
|
||||
onOpen = searchVm::open,
|
||||
onQueryChange = searchVm::onQueryChange,
|
||||
onClose = searchVm::close,
|
||||
onFocus = searchVm::focus,
|
||||
onUnfocus = searchVm::unfocus,
|
||||
),
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
top = RecipeTheme.spacing.sm,
|
||||
bottom = bottomInset + RecipeTheme.spacing.xs,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = searchState.isOpen,
|
||||
// Lock chrome region to the dock's height in both modes so
|
||||
// (a) the body above doesn't shift when search opens / closes,
|
||||
// and (b) the (shorter) search pill is centred vertically
|
||||
// inside the same band the dock occupies.
|
||||
modifier = Modifier.fillMaxWidth().height(63.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
// Exit is instant (no fade-out): the outgoing chrome cell —
|
||||
// dock OR search pill row — may still be playing its press
|
||||
// animation (the user's finger triggered the tap that switched
|
||||
// states). If we also fade it out, the half-faded pressed-up
|
||||
// button overlaps visually with the incoming pill, which reads
|
||||
// as "two things on screen at once". Instant exit makes the
|
||||
// hand-off feel clean while the press animation keeps running
|
||||
// off-screen on the now-removed branch.
|
||||
transitionSpec = {
|
||||
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
||||
ExitTransition.None
|
||||
},
|
||||
label = "AppShell bottom chrome",
|
||||
) { searchOpen ->
|
||||
if (searchOpen) {
|
||||
SearchPillRow(
|
||||
query = searchState.query,
|
||||
isFocused = searchState.isFocused,
|
||||
placeholder = stringResource(Res.string.search_placeholder),
|
||||
activeTab = navigator.activeTab,
|
||||
onQueryChange = searchVm::onQueryChange,
|
||||
onClose = searchVm::close,
|
||||
onFocusGained = searchVm::focus,
|
||||
onFocusLost = searchVm::unfocus,
|
||||
)
|
||||
} else {
|
||||
DefaultDockRow(
|
||||
activeTab = navigator.activeTab,
|
||||
onTabSelect = navigator::selectTab,
|
||||
onSearchTap = searchVm::open,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultDockRow(
|
||||
activeTab: BottomBarDestination,
|
||||
onTabSelect: (BottomBarDestination) -> Unit,
|
||||
onSearchTap: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DockBar(
|
||||
destinations = BottomBarDestination.entries,
|
||||
active = activeTab,
|
||||
collapsed = false,
|
||||
onTabSelect = onTabSelect,
|
||||
onCollapsedTap = { /* unreachable in default mode */ },
|
||||
modifier = Modifier.weight(1f),
|
||||
height = 63.dp,
|
||||
)
|
||||
Box(modifier = Modifier.size(63.dp)) {
|
||||
FloatingSearchButton(onClick = onSearchTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(DockBandHeight),
|
||||
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 = DockBandHeight,
|
||||
)
|
||||
Box(modifier = Modifier.size(DockBandHeight)) {
|
||||
FloatingSearchButton(onClick = onSearchTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shell
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
internal val DockBandHeight: Dp = 63.dp
|
||||
|
||||
@Composable
|
||||
fun rememberShellChromeHeight(): Dp {
|
||||
val spacing = RecipeTheme.spacing
|
||||
val navBottom =
|
||||
with(LocalDensity.current) {
|
||||
(WindowInsets.navigationBars.getBottom(this) / 2).toDp()
|
||||
}
|
||||
return navBottom + spacing.xs + DockBandHeight + spacing.sm
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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,195 @@
|
||||
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 SLIDE_OUTWARD_STIFFNESS = Spring.StiffnessMediumLow
|
||||
private const val SLIDE_SETTLE_STIFFNESS = Spring.StiffnessHigh
|
||||
private const val OVERLAY_FADE_IN_DURATION_MS = 120
|
||||
private const val OVERLAY_FADE_OUT_DURATION_MS = 40
|
||||
private const val SETTLE_EPSILON_PX = 0.5f
|
||||
|
||||
internal data class DockOverlayAnimations(
|
||||
val overlayCenterX: Float,
|
||||
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 = SLIDE_SETTLE_STIFFNESS,
|
||||
visibilityThreshold = SETTLE_EPSILON_PX,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
!wasPressed -> {
|
||||
wasPressed = true
|
||||
centerAnim.animateTo(
|
||||
clampedPressX,
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = SLIDE_OUTWARD_STIFFNESS,
|
||||
visibilityThreshold = SETTLE_EPSILON_PX,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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(OVERLAY_FADE_IN_DURATION_MS, easing = FastOutSlowInEasing),
|
||||
)
|
||||
} else {
|
||||
if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect
|
||||
releaseSlideStartX = centerAnim.value
|
||||
if (overlayAlphaAnim.value < 1f) {
|
||||
val tailMs =
|
||||
((1f - overlayAlphaAnim.value) * OVERLAY_FADE_IN_DURATION_MS)
|
||||
.toInt()
|
||||
.coerceAtLeast(0)
|
||||
if (tailMs > 0) {
|
||||
overlayAlphaAnim.animateTo(1f, tween(tailMs, easing = FastOutSlowInEasing))
|
||||
}
|
||||
}
|
||||
snapshotFlow {
|
||||
!centerAnim.isRunning &&
|
||||
abs(centerAnim.value - activeCenterXState.value) < SETTLE_EPSILON_PX
|
||||
}.first { it }
|
||||
coroutineScope {
|
||||
launch {
|
||||
overlayAlphaAnim.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, easing = FastOutSlowInEasing),
|
||||
)
|
||||
}
|
||||
launch {
|
||||
activeAlphaAnim.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(OVERLAY_FADE_OUT_DURATION_MS, 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.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.times
|
||||
import androidx.compose.ui.util.lerp
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val PressOverlayVerticalInset = 0.dp
|
||||
private val ActiveIndicatorVerticalInset = 5.dp
|
||||
private const val PRESS_OVERLAY_SCALE = 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, PRESS_OVERLAY_SCALE, overlayPeakProgress)
|
||||
val scaleY = lerp(activeStartScaleY, PRESS_OVERLAY_SCALE, overlayPeakProgress)
|
||||
|
||||
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
|
||||
val leftPx = overlayCenterX - overlayWidthPx / 2f
|
||||
GlassSurface(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset { IntOffset(leftPx.roundToInt(), 0) }
|
||||
.width(with(density) { overlayWidthPx.toDp() })
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = PressOverlayVerticalInset)
|
||||
.graphicsLayer {
|
||||
this.scaleX = scaleX
|
||||
this.scaleY = scaleY
|
||||
}.alpha(overlayAlpha),
|
||||
cornerRadius = cornerRadius,
|
||||
glassStyle = RecipeTheme.glass.dockPress,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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 DOCK_TAB_LABEL_FONT_SIZE_SP = 11
|
||||
private const val DOCK_TAB_LABEL_LINE_HEIGHT_SP = 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 = if(isActive) RecipeTheme.colors.accent else 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 = DOCK_TAB_LABEL_FONT_SIZE_SP.sp,
|
||||
lineHeight = DOCK_TAB_LABEL_LINE_HEIGHT_SP.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.ui.Modifier
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Search
|
||||
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.search_open_a11y
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.search
|
||||
package dev.ulfrx.recipe.ui.screens.shell.search
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
@@ -20,9 +20,9 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
@@ -53,7 +53,7 @@ fun SearchPillRow(
|
||||
query: String,
|
||||
isFocused: Boolean,
|
||||
placeholder: String,
|
||||
activeTab: BottomBarDestination,
|
||||
activeTab: DockDestination,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onFocusGained: () -> Unit,
|
||||
@@ -98,11 +98,12 @@ fun SearchPillRow(
|
||||
exit = sideButtonExit,
|
||||
) {
|
||||
DockBar(
|
||||
destinations = BottomBarDestination.entries,
|
||||
destinations = DockDestination.entries,
|
||||
active = activeTab,
|
||||
collapsed = true,
|
||||
onTabSelect = { /* unreachable while collapsed */ },
|
||||
onCollapsedTap = onClose,
|
||||
// Collapsed dock only emits a re-select of the active tab,
|
||||
// which here means "close the search overlay".
|
||||
onTabSelect = { onClose() },
|
||||
height = pillHeight,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.search
|
||||
package dev.ulfrx.recipe.ui.screens.shell.search
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -37,7 +37,7 @@ fun SearchPill(
|
||||
UnstyledIcon(
|
||||
imageVector = Lucide.Search,
|
||||
contentDescription = null,
|
||||
tint = RecipeTheme.colors.contentMuted,
|
||||
tint = RecipeTheme.colors.content,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shopping
|
||||
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.shopping_buy_count
|
||||
|
||||
@Composable
|
||||
fun ShoppingHorizonPill(
|
||||
selectedDate: LocalDate,
|
||||
expanded: Boolean,
|
||||
today: LocalDate,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onSelectDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
HorizonCalendarPill(
|
||||
selectedDate = selectedDate,
|
||||
expanded = expanded,
|
||||
today = today,
|
||||
onExpandedChange = onExpandedChange,
|
||||
onSelectDate = onSelectDate,
|
||||
trailing = {
|
||||
BasicText(
|
||||
text = stringResource(Res.string.shopping_buy_count, DUMMY_TO_BUY),
|
||||
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.contentMuted),
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DUMMY_TO_BUY = 12
|
||||
@@ -1,6 +1,5 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shopping
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
|
||||
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_shopping_subtitle
|
||||
import recipe.composeapp.generated.resources.empty_shopping_title
|
||||
import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||
|
||||
/**
|
||||
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
|
||||
* empty body with the shopping list + session UI.
|
||||
*
|
||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||
*/
|
||||
@Composable
|
||||
fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
|
||||
val today = remember { todayInSystemTz() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
||||
BottomOverlayScaffold(
|
||||
open = horizonState.isCalendarOpen,
|
||||
onDismiss = viewModel.horizon::close,
|
||||
bottomInset = rememberShellChromeHeight(),
|
||||
overlay = {
|
||||
ShoppingHorizonPill(
|
||||
selectedDate = horizonState.selectedDate,
|
||||
expanded = horizonState.isCalendarOpen,
|
||||
today = today,
|
||||
onExpandedChange = viewModel.horizon::setOpen,
|
||||
onSelectDate = viewModel.horizon::select,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -52,7 +60,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyState(
|
||||
icon = BottomBarDestination.Shopping.icon,
|
||||
icon = DockDestination.Shopping.icon,
|
||||
title = stringResource(Res.string.empty_shopping_title),
|
||||
subtitle = stringResource(Res.string.empty_shopping_subtitle),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shopping
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* UI state for [ShoppingScreen]. Phase 2.1 ships only the empty state. Phase 9
|
||||
* (Shopping List & Session Log) extends this with list items + session actions.
|
||||
*/
|
||||
data class ShoppingState(
|
||||
val isEmpty: Boolean = true,
|
||||
)
|
||||
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
|
||||
|
||||
class ShoppingViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(ShoppingState())
|
||||
val state: StateFlow<ShoppingState> = _state.asStateFlow()
|
||||
val horizon = HorizonCalendarHolder()
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@ package dev.ulfrx.recipe.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Semantic color tokens (UI-SPEC § Color, CONTEXT D-14, D-15).
|
||||
* Values are locked; do not introduce raw hex in screen code.
|
||||
*/
|
||||
public data class RecipeColors(
|
||||
val background: Color,
|
||||
val surface: Color,
|
||||
@@ -13,33 +9,45 @@ public data class RecipeColors(
|
||||
val content: Color,
|
||||
val contentMuted: Color,
|
||||
val accent: Color,
|
||||
val chromeActive: Color,
|
||||
val separator: Color,
|
||||
val borderCard: Color,
|
||||
val destructive: Color,
|
||||
val macroProtein: Color,
|
||||
val macroFat: Color,
|
||||
val macroCarbs: Color,
|
||||
)
|
||||
|
||||
public val LightRecipeColors: RecipeColors =
|
||||
RecipeColors(
|
||||
background = Color(0xFFF7F5F1),
|
||||
background = Color(0xFFEAE6DF),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f),
|
||||
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.6f),
|
||||
content = Color(0xFF0F1113),
|
||||
contentMuted = Color(0xFF6B6E73),
|
||||
accent = Color(0xFFD97757),
|
||||
chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f),
|
||||
separator = Color(0xFFE5E1DA),
|
||||
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
|
||||
destructive = Color(0xFFC0392B),
|
||||
macroProtein = Color(0xFF3B82F6),
|
||||
macroFat = Color(0xFFD97706),
|
||||
macroCarbs = Color(0xFFEA580C),
|
||||
)
|
||||
|
||||
public val DarkRecipeColors: RecipeColors =
|
||||
RecipeColors(
|
||||
background = Color(0xFF0F1113),
|
||||
surface = Color(0xFF1A1D21),
|
||||
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.18f),
|
||||
background = Color(0xFF1E2024),
|
||||
surface = Color(0xFF2A2D31),
|
||||
surfaceGlass = Color(0xFF313439).copy(alpha = 0.65f),
|
||||
content = Color(0xFFF1EFEA),
|
||||
contentMuted = Color(0xFF9AA0A6),
|
||||
accent = Color(0xFFE48A6E),
|
||||
separator = Color(0xFF2A2D31),
|
||||
accent = Color(0xFFFC8964),
|
||||
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
|
||||
separator = Color(0xFF383B40),
|
||||
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
||||
destructive = Color(0xFFE57368),
|
||||
macroProtein = Color(0xFF60A5FA),
|
||||
macroFat = Color(0xFFFBBF24),
|
||||
macroCarbs = Color(0xFFFB923C),
|
||||
)
|
||||
|
||||
@@ -1,28 +1,76 @@
|
||||
package dev.ulfrx.recipe.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
|
||||
/**
|
||||
* Glass surface defaults (UI-SPEC § Glass / Layout).
|
||||
* Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
|
||||
* floating button (plan 02.1-05).
|
||||
*/
|
||||
public data class RecipeGlass(
|
||||
val borderWidth: Dp,
|
||||
val shadowOffsetY: Dp,
|
||||
val shadowBlur: Dp,
|
||||
val shadowAlphaLight: Float,
|
||||
val shadowAlphaDark: Float,
|
||||
val blurRadius: Dp,
|
||||
data class RecipeGlass(
|
||||
val dock: RecipeGlassStyle,
|
||||
val dockPress: RecipeGlassStyle,
|
||||
val button: RecipeGlassStyle,
|
||||
val panel: RecipeGlassStyle,
|
||||
val chipOnGlass: RecipeGlassStyle,
|
||||
)
|
||||
|
||||
public val DefaultRecipeGlass: RecipeGlass =
|
||||
fun recipeGlassFor(colors: RecipeColors): RecipeGlass =
|
||||
RecipeGlass(
|
||||
borderWidth = 1.dp,
|
||||
shadowOffsetY = 8.dp,
|
||||
shadowBlur = 24.dp,
|
||||
shadowAlphaLight = 0.12f,
|
||||
shadowAlphaDark = 0.0f,
|
||||
blurRadius = 24.dp,
|
||||
dock = RecipeGlassStyle(
|
||||
refraction = 0.5f,
|
||||
curve = 0.4f,
|
||||
edge = 0.03f,
|
||||
dispersion = 0f,
|
||||
saturation = 1f,
|
||||
contrast = 1f,
|
||||
frost = 2.dp,
|
||||
tint = colors.surfaceGlass,
|
||||
),
|
||||
dockPress = RecipeGlassStyle(
|
||||
refraction = 0f,
|
||||
curve = 0f,
|
||||
edge = 0.03f,
|
||||
dispersion = 0.0f,
|
||||
saturation = 1f,
|
||||
contrast = 1f,
|
||||
frost = 0.dp,
|
||||
),
|
||||
button = RecipeGlassStyle(
|
||||
refraction = 0.3f,
|
||||
curve = 0.2f,
|
||||
edge = 0.03f,
|
||||
dispersion = 0.5f,
|
||||
saturation = 1f,
|
||||
contrast = 0.85f,
|
||||
frost = 5.dp,
|
||||
),
|
||||
panel = RecipeGlassStyle(
|
||||
refraction = 0f,
|
||||
curve = 0f,
|
||||
edge = 0.008f,
|
||||
dispersion = 0f,
|
||||
saturation = 1f,
|
||||
contrast = 1f,
|
||||
frost = 10.dp,
|
||||
tint = colors.surfaceGlass,
|
||||
),
|
||||
chipOnGlass = RecipeGlassStyle(
|
||||
refraction = 0f,
|
||||
curve = 0f,
|
||||
edge = 0.1f,
|
||||
dispersion = 0.03f,
|
||||
saturation = 0.5f,
|
||||
contrast = 1.5f,
|
||||
frost = 5.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,
|
||||
val tint: Color? = null,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* Recipe theme entry point (CONTEXT D-14, D-15).
|
||||
@@ -32,35 +33,36 @@ public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
|
||||
public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
|
||||
val recipeGlass = remember(recipeColors) { recipeGlassFor(recipeColors) }
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalRecipeColors provides recipeColors,
|
||||
LocalRecipeTypography provides DefaultRecipeTypography,
|
||||
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
||||
LocalRecipeShapes provides DefaultRecipeShapes,
|
||||
LocalRecipeGlass provides DefaultRecipeGlass,
|
||||
LocalRecipeGlass provides recipeGlass,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
public object RecipeTheme {
|
||||
public val colors: RecipeColors
|
||||
object RecipeTheme {
|
||||
val colors: RecipeColors
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeColors.current
|
||||
|
||||
public val typography: RecipeTypography
|
||||
val typography: RecipeTypography
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeTypography.current
|
||||
|
||||
public val spacing: RecipeSpacing
|
||||
val spacing: RecipeSpacing
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeSpacing.current
|
||||
|
||||
public val shapes: RecipeShapes
|
||||
val shapes: RecipeShapes
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeShapes.current
|
||||
|
||||
public val glass: RecipeGlass
|
||||
val glass: RecipeGlass
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeGlass.current
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
|
||||
package dev.ulfrx.recipe.ui.keyboard
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.cinterop.DoubleVar
|
||||
import kotlinx.cinterop.allocArray
|
||||
import kotlinx.cinterop.get
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.sizeOf
|
||||
import kotlinx.cinterop.useContents
|
||||
import platform.CoreGraphics.CGRect
|
||||
import platform.Foundation.NSNotificationCenter
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.NSOperationQueue
|
||||
import platform.Foundation.NSValue
|
||||
import platform.UIKit.UIKeyboardAnimationDurationUserInfoKey
|
||||
import platform.UIKit.UIKeyboardFrameEndUserInfoKey
|
||||
import platform.UIKit.UIKeyboardWillChangeFrameNotification
|
||||
import platform.UIKit.UIScreen
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal actual fun rememberKeyboardTransitionState(): KeyboardTransitionState {
|
||||
val currentInset = WindowInsets.ime.asPaddingValues().calculateBottomPadding()
|
||||
var targetInset by remember { mutableStateOf(0.dp) }
|
||||
var animationDurationMillis by remember { mutableStateOf(IosDefaultKeyboardAnimationDurationMillis) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val observer =
|
||||
NSNotificationCenter.defaultCenter.addObserverForName(
|
||||
name = UIKeyboardWillChangeFrameNotification,
|
||||
`object` = null,
|
||||
queue = NSOperationQueue.mainQueue,
|
||||
usingBlock = { notification ->
|
||||
val userInfo = notification?.userInfo ?: return@addObserverForName
|
||||
val frameValue = userInfo[UIKeyboardFrameEndUserInfoKey] as? NSValue
|
||||
?: return@addObserverForName
|
||||
val durationValue = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? NSNumber
|
||||
|
||||
val screenHeight =
|
||||
UIScreen.mainScreen.bounds.useContents {
|
||||
size.height
|
||||
}
|
||||
val keyboardTop =
|
||||
memScoped {
|
||||
// iOS app targets are arm64; CGRect is x, y, width, height
|
||||
// as CGFloat/Double fields.
|
||||
val keyboardFrame = allocArray<DoubleVar>(CGRectDoubleFieldCount)
|
||||
frameValue.getValue(
|
||||
value = keyboardFrame,
|
||||
size = sizeOf<CGRect>().toULong(),
|
||||
)
|
||||
keyboardFrame[CGRectOriginYFieldIndex]
|
||||
}
|
||||
val targetHeight = (screenHeight - keyboardTop).coerceAtLeast(0.0)
|
||||
|
||||
targetInset = targetHeight.toFloat().dp
|
||||
animationDurationMillis =
|
||||
durationValue?.doubleValue
|
||||
?.times(MillisPerSecond)
|
||||
?.roundToInt()
|
||||
?.takeIf { it > 0 }
|
||||
?: IosDefaultKeyboardAnimationDurationMillis
|
||||
},
|
||||
)
|
||||
|
||||
onDispose {
|
||||
NSNotificationCenter.defaultCenter.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
return KeyboardTransitionState(
|
||||
currentInset = currentInset,
|
||||
targetInset = targetInset,
|
||||
animationDurationMillis = animationDurationMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private const val IosDefaultKeyboardAnimationDurationMillis = 250
|
||||
private const val MillisPerSecond = 1_000.0
|
||||
private const val CGRectDoubleFieldCount = 4
|
||||
private const val CGRectOriginYFieldIndex = 1
|
||||
@@ -14,6 +14,7 @@ koin = "4.2.1"
|
||||
koin-plugin = "1.0.0-RC2"
|
||||
kotlin = "2.3.20"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
kotlinx-datetime = "0.6.2"
|
||||
kotlinx-serialization = "1.7.3"
|
||||
ktor = "3.4.2"
|
||||
lokksmith = "0.13.0"
|
||||
@@ -33,6 +34,7 @@ kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", versio
|
||||
|
||||
# kotlinx.serialization (shared DTOs — D-27)
|
||||
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
@@ -90,6 +92,7 @@ lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref =
|
||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
||||
|
||||
navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
||||
androidx-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
|
||||
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
|
||||
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
|
||||
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
||||
|
||||
@@ -23,11 +23,15 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
|
||||
// is the only allowed runtime dependency in shared/commonMain — D-19 / INFRA-06
|
||||
// forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers
|
||||
// (composeApp, server) inherit the @Serializable runtime without each
|
||||
// re-declaring it.
|
||||
// and kotlinx.datetime are the only allowed runtime dependencies in
|
||||
// shared/commonMain — D-19 / INFRA-06 forbids Ktor, Compose, SQLDelight,
|
||||
// Koin, Kermit. `api(...)` so consumers (composeApp, server) inherit the
|
||||
// @Serializable runtime + datetime types without each re-declaring them.
|
||||
api(libs.kotlinx.serializationJson)
|
||||
// Domain types need Instant (SyncMeta.updatedAt/createdAt/deletedAt) and
|
||||
// LocalDate (PlanEntry.date). kotlinx.datetime is the project's locked
|
||||
// datetime lib per CLAUDE.md; pure types, no platform deps.
|
||||
api(libs.kotlinx.datetime)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package dev.ulfrx.recipe.shared.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ── Value-typed metadata ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Macros per 100 g (or 100 ml for liquid ingredients). Daily-total aggregation
|
||||
* (PLAN-13) reads these from ingredients/products and scales by the canonical
|
||||
* quantity at meal time.
|
||||
*/
|
||||
@Serializable
|
||||
public data class NutritionPer100(
|
||||
public val kcal: Double,
|
||||
public val protein: Double,
|
||||
public val fat: Double,
|
||||
public val carbs: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* Typical retail pack for an ingredient or a specific product (e.g.
|
||||
* `PurchasePack(125.0, "125 g")`). Shopping list rounding uses this to suggest
|
||||
* realistic pack-sized purchases instead of raw recipe weights.
|
||||
*/
|
||||
@Serializable
|
||||
public data class PurchasePack(
|
||||
public val amount: Double,
|
||||
public val label: String,
|
||||
)
|
||||
|
||||
// ── Household-scoped taxonomy ────────────────────────────────────────────
|
||||
//
|
||||
// IngredientCategory and MealSlot are user-curated per household: each
|
||||
// household ships with a Polish default list at creation (Phase 3 server
|
||||
// concern) and can rename / reorder / extend freely. Both LWW-sync via SyncMeta
|
||||
// the same way other household data does.
|
||||
|
||||
/**
|
||||
* A pantry / shopping grouping the household uses (`Pieczywo`, `Nabiał`,
|
||||
* `Mięso i ryby`, …). [ordinal] drives display order in the pantry and
|
||||
* shopping list. [name] is localized per the household's locale palette.
|
||||
*/
|
||||
@Serializable
|
||||
public data class IngredientCategory(
|
||||
public val id: IngredientCategoryId,
|
||||
public val sync: SyncMeta,
|
||||
public val name: LocalizedString,
|
||||
public val ordinal: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* A meal-time slot a household plans into (`Śniadanie`, `Drugie śniadanie`,
|
||||
* `Obiad`, `Przekąska`, `Kolacja`, plus anything custom). [ordinal] drives the
|
||||
* within-day order in the planner. Slots are referenced by [PlanEntry.slotId].
|
||||
*
|
||||
* Recipes don't pin themselves to specific slots — slot affinity is a UI-layer
|
||||
* match between [Recipe.tags] and [name] localized values, so households can
|
||||
* rename slots without invalidating the catalog.
|
||||
*/
|
||||
@Serializable
|
||||
public data class MealSlot(
|
||||
public val id: MealSlotId,
|
||||
public val sync: SyncMeta,
|
||||
public val name: LocalizedString,
|
||||
public val ordinal: Int,
|
||||
)
|
||||
|
||||
// ── Catalog entities (server-owned in v1, household-editable later) ──────
|
||||
//
|
||||
// Ingredient / Product / Recipe carry SyncMeta so that future client-side
|
||||
// catalog writes land on the same LWW path as everything else; v1 only reads
|
||||
// them on the client.
|
||||
|
||||
/**
|
||||
* A catalog-level ingredient (mąka, oliwa, jajko, …). [pantryUnit] is the
|
||||
* canonical unit this ingredient is tracked in for pantry math; recipe lines
|
||||
* targeting this ingredient must produce a [Quantity] in the same unit (or one
|
||||
* that converts cleanly — pieces ↔ grams via [weightPerPieceG]).
|
||||
*
|
||||
* [weightPerPieceG] is only meaningful when [pantryUnit] == [MeasurementUnit.PIECE];
|
||||
* leaving it null on a piece-tracked ingredient blocks nutrition aggregation.
|
||||
* [purchasePack] feeds shopping rounding; [nutritionPer100] feeds daily totals.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Ingredient(
|
||||
public val id: IngredientId,
|
||||
public val sync: SyncMeta,
|
||||
public val name: LocalizedString,
|
||||
public val categoryId: IngredientCategoryId,
|
||||
public val pantryUnit: MeasurementUnit,
|
||||
public val weightPerPieceG: Double? = null,
|
||||
public val purchasePack: PurchasePack? = null,
|
||||
public val nutritionPer100: NutritionPer100? = null,
|
||||
public val imagePath: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A branded / packaged variant of an [Ingredient]. Products carry their own
|
||||
* [nutritionPer100] because brand A's twaróg has different macros than brand
|
||||
* B's, and per-entry [PlanCustomization.overrides] can pick a specific product.
|
||||
* [brand] is plain `String?` — brand names are proper nouns and don't translate.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Product(
|
||||
public val id: ProductId,
|
||||
public val sync: SyncMeta,
|
||||
public val ingredientId: IngredientId,
|
||||
public val name: LocalizedString,
|
||||
public val brand: String? = null,
|
||||
public val pack: PurchasePack,
|
||||
public val nutritionPer100: NutritionPer100? = null,
|
||||
public val imagePath: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One ingredient line on a recipe. [alternatives] is metadata only — the
|
||||
* planner UI surfaces these as suggested swaps, but the actual per-entry
|
||||
* substitution is recorded in [IngredientCustomization.substituteWith].
|
||||
*/
|
||||
@Serializable
|
||||
public data class RecipeIngredient(
|
||||
public val ingredientId: IngredientId,
|
||||
public val quantity: Quantity,
|
||||
public val alternatives: List<IngredientId> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog-level recipe definition. Synced via the same LWW path as household
|
||||
* data so future client-side recipe editing lands additively. Recipes are
|
||||
* server-seeded in v1 and read-only on the client.
|
||||
*
|
||||
* No `allowedSlots` field — slot affinity is a UI-layer concern using [tags]
|
||||
* matched against [MealSlot.name] localized values. Recipes ship with Polish
|
||||
* slot tags so default households work out of the box.
|
||||
*
|
||||
* [nutritionPerServing] is the cached pre-customization total; the calculator
|
||||
* skips the per-ingredient walk when no [PlanCustomization] is present.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Recipe(
|
||||
public val id: RecipeId,
|
||||
public val sync: SyncMeta,
|
||||
public val title: LocalizedString,
|
||||
public val minutes: Int,
|
||||
public val tags: List<String> = emptyList(),
|
||||
public val steps: List<LocalizedString> = emptyList(),
|
||||
public val ingredients: List<RecipeIngredient>,
|
||||
public val nutritionPerServing: NutritionPer100,
|
||||
public val imagePath: String? = null,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user