Reorganize dock and search

This commit is contained in:
2026-05-11 22:01:34 +02:00
parent 573b4562c2
commit 15d2d9ad13
15 changed files with 510 additions and 338 deletions

View File

@@ -68,7 +68,7 @@ fun App() {
// RootRoute.Login -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
// RootRoute.Shell -> AppShell()
// }
//for easier tests authentication is turned off
// for easier tests authentication is turned off
AppShell()
}
}

View File

@@ -5,24 +5,23 @@ import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.shell.ShellViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
val shellModule =
module {
// Shell-level state machine.
viewModel<ShellViewModel>()
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
// Active-tab tracking lives in NavController (currentBackStackEntryAsState),
// not in a shell-level VM, so there is no ShellViewModel to register.
viewModel<PlannerViewModel>()
viewModel<RecipesViewModel>()
viewModel<PantryViewModel>()
viewModel<ShoppingViewModel>()
// Per-tab Search ViewModels — pure echo this phase; Phase 5 / 8 inject
// their respective SearchSource implementations.
// their respective SearchSource implementations. Both implement
// SearchControls so the shared ProvideSearchChrome composable drives them.
viewModel<RecipesSearchViewModel>()
viewModel<PantrySearchViewModel>()
}

View File

@@ -8,8 +8,6 @@ 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.search_placeholder_pantry
import recipe.composeapp.generated.resources.search_placeholder_recipes
import recipe.composeapp.generated.resources.shell_tab_pantry
import recipe.composeapp.generated.resources.shell_tab_planner
import recipe.composeapp.generated.resources.shell_tab_recipes
@@ -21,43 +19,35 @@ import recipe.composeapp.generated.resources.shell_tab_shopping
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal listing
* order, which research confirmed is non-binding.
*
* `hasSearch` drives D-06: search affordance lives on Recipes + Pantry only.
* `searchPlaceholder` is non-null IFF `hasSearch` is true.
* Per-tab contextual chrome (search button on Recipes / Pantry, future filter
* buttons elsewhere) is owned by each screen via the slot pattern in
* [dev.ulfrx.recipe.ui.screens.shell.ShellChromeState]. This enum is therefore
* intentionally minimal: route + label + icon, nothing about feature affordances.
*/
enum class BottomBarDestination(
val graphRoute: Any,
val labelRes: StringResource,
val icon: ImageVector,
val hasSearch: Boolean,
val searchPlaceholder: StringResource?,
) {
Planner(
graphRoute = PlannerGraph,
labelRes = Res.string.shell_tab_planner,
icon = Lucide.CalendarDays,
hasSearch = false,
searchPlaceholder = null,
),
Recipes(
graphRoute = RecipesGraph,
labelRes = Res.string.shell_tab_recipes,
icon = Lucide.BookOpenText,
hasSearch = true,
searchPlaceholder = Res.string.search_placeholder_recipes,
),
Pantry(
graphRoute = PantryGraph,
labelRes = Res.string.shell_tab_pantry,
icon = Lucide.Package,
hasSearch = true,
searchPlaceholder = Res.string.search_placeholder_pantry,
),
Shopping(
graphRoute = ShoppingGraph,
labelRes = Res.string.shell_tab_shopping,
icon = Lucide.ShoppingCart,
hasSearch = false,
searchPlaceholder = null,
),
;

View File

@@ -9,10 +9,12 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
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.RecipesSearchViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
@@ -62,7 +64,8 @@ fun RootNavHost(
navController.getBackStackEntry(RecipesGraph)
}
val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
RecipesScreen(viewModel = vm)
val searchVm: RecipesSearchViewModel = koinViewModel(viewModelStoreOwner = parent)
RecipesScreen(viewModel = vm, searchViewModel = searchVm)
}
}
@@ -74,7 +77,8 @@ fun RootNavHost(
navController.getBackStackEntry(PantryGraph)
}
val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent)
PantryScreen(viewModel = vm)
val searchVm: PantrySearchViewModel = koinViewModel(viewModelStoreOwner = parent)
PantryScreen(viewModel = vm, searchViewModel = searchVm)
}
}

View File

@@ -57,4 +57,3 @@ fun FloatingSearchButton(
}
}
}

View File

@@ -28,7 +28,7 @@ fun rememberGlassBackdropState(): GlassBackdropState {
val liquidState = rememberLiquidBackdropHandle()
return remember(liquidState) {
GlassBackdropState(
liquidState = liquidState
liquidState = liquidState,
)
}
}

View File

@@ -0,0 +1,214 @@
package dev.ulfrx.recipe.ui.components.search
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.screens.shell.LocalShellChrome
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y
/**
* Wires a feature's [SearchControls] VM into the shell's bottom-bar slots
* (`LocalShellChrome.trailingSlot` and `LocalShellChrome.bottomOverlay`).
*
* Call this once from any feature screen that wants the shared search affordance
* (Recipes, Pantry, …). The shell does not need to know which features have search;
* it just renders whatever slots the active screen has supplied.
*
* ## What it does
*
* - When `controls.state.isOpen == false`: trailing slot becomes a [FloatingSearchButton]
* that calls `controls.open()`. Bottom overlay stays null (default DockBar visible).
*
* - When `controls.state.isOpen == true`: bottom overlay takes over the row,
* showing a collapsed [DockBar] icon + a full-width [SearchPill] + an optional
* keyboard-dismiss button.
*
* ## Ownership protocol (important)
*
* The slot lambdas are built once per [SearchControls] instance via [remember] so
* their referential identity is stable across recompositions. On disposal we use
* a `===` identity check before nulling — that way, if the next active screen
* has already taken ownership of a slot in between (race during NavHost
* destination swap), our late-running disposer can't clobber it.
*
* @param controls per-feature search VM (Recipes / Pantry / …).
* @param placeholder localized placeholder for the [SearchPill] text field.
* @param activeTab passed to [DockBar] in collapsed mode so the right tab icon
* shows in the collapsed circle. Pass [BottomBarDestination] of the screen
* invoking this composable (e.g. `BottomBarDestination.Recipes`).
*/
@Composable
fun ProvideSearchChrome(
controls: SearchControls,
placeholder: StringResource,
activeTab: BottomBarDestination,
) {
val chrome = LocalShellChrome.current
val state by controls.state.collectAsStateWithLifecycle()
val placeholderText = stringResource(placeholder)
// Stable slot lambdas — survive recomposition as long as `controls` and
// `placeholderText` don't change. Stable identity is what makes the `===`
// ownership guards in onDispose correct.
val trailing: @Composable () -> Unit =
remember(controls) {
{ FloatingSearchButton(onClick = controls::open) }
}
val overlay: @Composable () -> Unit =
remember(controls, placeholderText, activeTab) {
{
// Subscribe to state INSIDE the slot so query updates only
// recompose this overlay, not the rest of AppShell.
val s by controls.state.collectAsStateWithLifecycle()
SearchPillRow(
query = s.query,
placeholder = placeholderText,
activeTab = activeTab,
onQueryChange = controls::onQueryChange,
onClose = controls::close,
onClear = controls::clear,
)
}
}
// Drive chrome slots from isOpen. DisposableEffect re-runs whenever
// isOpen flips, swapping which slot is populated.
DisposableEffect(state.isOpen, trailing, overlay, chrome) {
if (state.isOpen) {
chrome.trailingSlot = null
chrome.bottomOverlay = overlay
} else {
chrome.bottomOverlay = null
chrome.trailingSlot = trailing
}
onDispose {
// Only clear if WE'RE still the slot owner. A `===` check prevents
// a late dispose from clobbering slots already claimed by the next
// active screen.
if (chrome.trailingSlot === trailing) chrome.trailingSlot = null
if (chrome.bottomOverlay === overlay) chrome.bottomOverlay = null
}
}
}
/**
* Replacement bottom row rendered when search is open: collapsed [DockBar] icon
* (tap = close search), full-width [SearchPill], and an optional X button to
* clear the query + dismiss the keyboard while the field is focused.
*
* Geometry mirrors the previous [dev.ulfrx.recipe.ui.screens.shell.AppShell]
* branch: 45dp height for all three children, capsule shapes, [RecipeTheme.spacing.sm]
* gap between cells.
*/
@Composable
private fun SearchPillRow(
query: String,
placeholder: String,
activeTab: BottomBarDestination,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
onClear: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val pillHeight = 45.dp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = true,
onTabSelect = { /* unreachable while collapsed */ },
onCollapsedTap = onClose,
height = pillHeight,
)
SearchPill(
query = query,
onQueryChange = onQueryChange,
onFocusChanged = { focused = it },
placeholder = placeholder,
modifier = Modifier.weight(1f),
height = pillHeight,
)
if (focused) {
DismissSearchKeyboardButton(
onClick = {
onClear()
focusManager.clearFocus()
focused = false
},
size = pillHeight,
)
}
}
}
/**
* 45dp circular Liquid-glass button that clears the active query and dismisses
* the keyboard. Visible only while the [SearchPill] field is focused.
*
* Lifted verbatim from the previous private helper in
* [dev.ulfrx.recipe.ui.screens.shell.AppShell] — moved here because keyboard
* dismissal is conceptually part of the search affordance, not the shell.
*/
@Composable
private fun DismissSearchKeyboardButton(
onClick: () -> Unit,
size: Dp,
) {
val a11y = stringResource(Res.string.search_dismiss_keyboard_a11y)
GlassSurface(
modifier = Modifier.size(size),
cornerRadius = size / 2,
) {
UnstyledButton(
onClick = onClick,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = Lucide.X,
contentDescription = a11y,
tint = RecipeTheme.colors.content,
modifier = Modifier.size(24.dp),
)
}
}
}
}

View File

@@ -0,0 +1,57 @@
package dev.ulfrx.recipe.ui.components.search
import kotlinx.coroutines.flow.StateFlow
/**
* Per-tab search state shape. Both [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel]
* and [dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel] expose this via
* their `state: StateFlow<SearchState>`.
*
* - [isOpen] — whether the search affordance is open on this tab.
* - [query] — the current query echo (D-07: just an echo this phase; results
* plumbing arrives in Phase 5 / 8 for Recipes / Pantry respectively).
*/
data class SearchState(
val isOpen: Boolean = false,
val query: String = "",
)
/**
* Phase 5 (Recipes) and Phase 8 (Pantry) implement and inject a real
* [SearchSource]; Phase 2.1 leaves it null. The Search VMs accept a nullable
* source today so Phase 5 / 8 only inject a dependency, not refactor the VM.
*
* Defined in `ui.components.search` (the canonical home for search shapes) —
* Phase 5 introduces the Recipes-specific implementation; Phase 8 either reuses
* or shadows with its own version. Either way, Phase 2.1 does NOT call into
* [SearchSource].
*/
interface SearchSource {
// Phase 5 / 8 add: fun observe(query: String): Flow<List<*>>
}
/**
* Minimal contract a feature ViewModel must satisfy to participate in the
* shared bottom-bar search chrome via [ProvideSearchChrome].
*
* Both Recipes and Pantry search VMs already had this exact shape — making it
* an explicit interface lets [ProvideSearchChrome] take a stable VM reference
* and keep its slot lambdas referentially stable across recompositions
* (important for the `===` identity guard in the chrome ownership protocol).
*/
interface SearchControls {
/** Hot state stream — UI subscribes via `collectAsStateWithLifecycle()`. */
val state: StateFlow<SearchState>
/** Open the search affordance. */
fun open()
/** Close the search affordance. D-08: closing also clears the query. */
fun close()
/** Update the query echo. */
fun onQueryChange(q: String)
/** D-07: clear the query but keep the affordance open. */
fun clear()
}

View File

@@ -16,22 +16,36 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
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_pantry_subtitle
import recipe.composeapp.generated.resources.empty_pantry_title
import recipe.composeapp.generated.resources.search_placeholder_pantry
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.
*
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not
* know that Pantry has a search button.
*/
@Composable
fun PantryScreen(viewModel: PantryViewModel) {
fun PantryScreen(
viewModel: PantryViewModel,
searchViewModel: PantrySearchViewModel,
) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
ProvideSearchChrome(
controls = searchViewModel,
placeholder = Res.string.search_placeholder_pantry,
activeTab = BottomBarDestination.Pantry,
)
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {

View File

@@ -1,17 +1,19 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.screens.recipes.SearchSource
import dev.ulfrx.recipe.ui.screens.recipes.SearchState
import dev.ulfrx.recipe.ui.components.search.SearchControls
import dev.ulfrx.recipe.ui.components.search.SearchSource
import dev.ulfrx.recipe.ui.components.search.SearchState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* PantrySearchViewModel — semantic parity with [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel].
* Both VMs share [SearchState] and [SearchSource] from `ui.screens.recipes` (the
* canonical home for the search-state shape).
* PantrySearchViewModel — semantic parity with
* [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel]. Both VMs share
* [SearchState] and [SearchSource] from `ui.components.search` and implement
* [SearchControls] so the same `ProvideSearchChrome` helper drives both tabs.
*
* Phase 8 (Pantry) injects a Pantry-specific SearchSource. This phase: pure echo.
* Constructor parameter has a default so Koin can register without a source today.
@@ -19,25 +21,26 @@ import kotlinx.coroutines.flow.update
class PantrySearchViewModel(
@Suppress("UNUSED_PARAMETER")
private val searchSource: SearchSource? = null,
) : ViewModel() {
) : ViewModel(),
SearchControls {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
override val state: StateFlow<SearchState> = _state.asStateFlow()
fun open() {
override fun open() {
_state.update { it.copy(isOpen = true) }
}
/** D-08: closing clears the query. */
fun close() {
override fun close() {
_state.value = SearchState(isOpen = false, query = "")
}
fun onQueryChange(q: String) {
override fun onQueryChange(q: String) {
_state.update { it.copy(query = q) }
}
/** D-07: clear() resets only the query, preserves isOpen. */
fun clear() {
override fun clear() {
_state.update { it.copy(query = "") }
}
}

View File

@@ -16,22 +16,39 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
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.search_placeholder_recipes
import recipe.composeapp.generated.resources.shell_tab_recipes
/**
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
* empty body with the recipe catalog grid.
*
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not
* know that Recipes has a search button. When this screen leaves composition
* (tab switch), the chrome slots clear themselves automatically.
*/
@Composable
fun RecipesScreen(viewModel: RecipesViewModel) {
fun RecipesScreen(
viewModel: RecipesViewModel,
searchViewModel: RecipesSearchViewModel,
) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
// Push search chrome (FloatingSearchButton / SearchPill overlay) into the
// shell's bottom-bar slots. Lifecycle is tied to this composition.
ProvideSearchChrome(
controls = searchViewModel,
placeholder = Res.string.search_placeholder_recipes,
activeTab = BottomBarDestination.Recipes,
)
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {

View File

@@ -1,68 +1,50 @@
package dev.ulfrx.recipe.ui.screens.recipes
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.search.SearchControls
import dev.ulfrx.recipe.ui.components.search.SearchSource
import dev.ulfrx.recipe.ui.components.search.SearchState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Per-tab search state for [RecipesSearchViewModel] and [PantrySearchViewModel]
* (RESEARCH § Pattern 4, lines 390-410).
*
* - [isOpen] — whether the search affordance is open on this tab.
* - [query] — the current query echo (D-07: just an echo this phase; results
* plumbing arrives in Phase 5 / 8 for Recipes / Pantry respectively).
*/
data class SearchState(
val isOpen: Boolean = false,
val query: String = "",
)
/**
* Phase 5 (Recipes) and Phase 8 (Pantry) implement and inject a real
* [SearchSource]; Phase 2.1 leaves it null. The Search VMs accept a nullable
* source today so Phase 5 / 8 only inject a dependency, not refactor the VM.
*
* Defined here (in `recipes/` package) as a marker — Phase 5 introduces the
* Recipes-specific implementation; Phase 8 may either reuse or shadow with its
* own version. Either way, this phase does NOT call into [SearchSource].
*/
interface SearchSource {
// Phase 5 / 8 add: fun observe(query: String): Flow<List<*>>
}
/**
* RecipesSearchViewModel per RESEARCH § Pattern 4. Pure state machine; no I/O
* this phase (the [searchSource] parameter is the Phase 5 extension hook —
* RESEARCH line 410). Constructor parameter has a default so Koin can register
* with `viewModel { RecipesSearchViewModel() }` and Phase 5 swaps to
* `viewModel { RecipesSearchViewModel(searchSource = get()) }`.
*
* Implements [SearchControls] so it can plug into the shared
* [dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome] helper alongside
* [dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel].
*/
class RecipesSearchViewModel(
@Suppress("UNUSED_PARAMETER")
private val searchSource: SearchSource? = null,
) : ViewModel() {
) : ViewModel(),
SearchControls {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
override val state: StateFlow<SearchState> = _state.asStateFlow()
/** Open the search affordance. */
fun open() {
override fun open() {
_state.update { it.copy(isOpen = true) }
}
/** D-08: closing clears the query — reopening starts blank. */
fun close() {
override fun close() {
_state.value = SearchState(isOpen = false, query = "")
}
/** Query echo. Phase 5 will plumb `searchSource.observe(...)` here. */
fun onQueryChange(q: String) {
override fun onQueryChange(q: String) {
_state.update { it.copy(query = q) }
}
/** D-07: clear() resets only the query and keeps isOpen=true. */
fun clear() {
override fun clear() {
_state.update { it.copy(query = "") }
}
}

View File

@@ -1,9 +1,14 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -14,27 +19,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
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.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.PantryGraph
import dev.ulfrx.recipe.navigation.PlannerGraph
@@ -43,35 +39,39 @@ import dev.ulfrx.recipe.navigation.RootNavHost
import dev.ulfrx.recipe.navigation.ShoppingGraph
import dev.ulfrx.recipe.navigation.navigateToTab
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.GlassSurface
import dev.ulfrx.recipe.ui.components.search.SearchPill
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
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_dismiss_keyboard_a11y
/**
* Authenticated root composable per RESEARCH § Code Example 2 (lines 514-565).
* Authenticated root composable. Hosts navigation and the bottom-bar chrome
* skeleton, but is **agnostic about feature concerns** like search.
*
* Layout responsibilities:
* ## Layout
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
* - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource]
* so Liquid chrome samples the screen body through [LocalGlassBackdropState].
* - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill]
* (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible).
* Chrome consumes [WindowInsets.navigationBars] + [imePadding] explicitly per
* Pitfall F — does NOT use safeContentPadding() at this layer.
* - [FloatingSearchButton] aligned [Alignment.BottomEnd], visible only when
* !ui.searchOpen && active.hasSearch (D-06).
* so Liquid chrome samples the screen body through `LocalGlassBackdropState`.
* - Bottom chrome (overlay): the active screen contributes its own contextual
* chrome via [ShellChromeState] / [LocalShellChrome] (see [ShellChrome.kt]).
* AppShell renders one of two modes:
* * `bottomOverlay` non-null → render the screen-supplied overlay full-width.
* * `bottomOverlay` null → render the default [DockBar] + a 56dp trailing
* slot whose contents come from `chrome.trailingSlot` (or empty).
* - The two modes are wrapped in [AnimatedContent] for a smooth cross-fade when a
* feature toggles its overlay (e.g. opening / closing search).
* - Pitfall F: navigationBars + ime padding only; no `safeContentPadding()`.
*
* Active-tab tracking: derived from the NavHost's current back stack entry's route
* hierarchy via [hasRoute]. The shell's [ShellViewModel] mirrors active tab so chrome
* can react synchronously even before NavHost navigation completes.
* ## Active-tab tracking
* Derived from the NavHost's current back stack entry hierarchy via [hasRoute].
* No mirror state in a ShellViewModel is needed — `currentBackStackEntryAsState()`
* is the single source of truth, and re-derives synchronously after every nav.
*
* ## What used to live here, and why it moved
* The previous version of this file knew about `RecipesSearchViewModel` and
* `PantrySearchViewModel` directly, with `when (activeTab)` branches forwarding
* open/close/clear into the right VM. That coupling was unnecessary: the active
* screen already has the VM and is the right place to express its own chrome.
* The slot pattern means adding a contextual button to a future tab (a Planner
* filter, say) is a one-file change in that screen — AppShell never grows.
*/
@Preview
@Composable
@@ -83,162 +83,66 @@ fun AppShell(modifier: Modifier = Modifier) {
backStack?.toBottomBarDestination() ?: BottomBarDestination.Default
}
val vm: ShellViewModel = koinViewModel()
val ui by vm.state.collectAsStateWithLifecycle()
val recipesSearchVm: RecipesSearchViewModel = koinViewModel()
val recipesSearch by recipesSearchVm.state.collectAsStateWithLifecycle()
val pantrySearchVm: PantrySearchViewModel = koinViewModel()
val pantrySearch by pantrySearchVm.state.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
var searchFieldFocused by remember { mutableStateOf(false) }
val dockHeight = 56.dp
val activeSearchHeight = 45.dp
// Single chrome-state holder for the lifetime of the shell. Provided to
// descendants via LocalShellChrome.
val chrome = remember { ShellChromeState() }
fun closeActiveSearch() {
when (activeTab) {
BottomBarDestination.Recipes -> recipesSearchVm.close()
BottomBarDestination.Pantry -> pantrySearchVm.close()
else -> Unit
}
vm.closeSearch()
searchFieldFocused = false
}
// NB: we deliberately do NOT clear chrome on activeTab changes. The active
// screen's `DisposableEffect` cleanup (in `ProvideSearchChrome` or similar)
// is responsible for releasing its slots when it leaves composition. Doing
// a redundant clear here would race with the new screen's setup.
fun clearActiveSearchAndDismissKeyboard() {
when (activeTab) {
BottomBarDestination.Recipes -> recipesSearchVm.clear()
BottomBarDestination.Pantry -> pantrySearchVm.clear()
else -> Unit
}
focusManager.clearFocus()
searchFieldFocused = false
}
// Sync ShellViewModel.activeTab with NavHost-derived activeTab for back-button
// and deep-link cases. onTabChanged also clears any open search per D-08.
LaunchedEffect(activeTab) {
if (ui.activeTab != activeTab) {
vm.onTabChanged(activeTab)
}
searchFieldFocused = false
}
LaunchedEffect(ui.searchOpen) {
if (!ui.searchOpen) {
searchFieldFocused = false
focusManager.clearFocus()
}
}
Box(
modifier =
modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
// Body — RootNavHost fills the available space and is the shared source layer
// for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
RootNavHost(
navController = navController,
modifier = Modifier.fillMaxSize(),
)
}
// Bottom chrome overlay — single Row spanning the full width with two
// layout modes:
// - Closed: DockBar (fills, weighted 4 tabs) + 56dp trailing slot
// that holds FloatingSearchButton on Recipes/Pantry (D-06), empty
// on other tabs (placeholder for future contextual buttons).
// - Open: collapsed dock icon button (56dp left) + SearchPill (fills)
// + optional 56dp keyboard-dismiss button while the field is focused.
// Pitfall F: navigationBars + ime padding only; no safeContentPadding.
Row(
CompositionLocalProvider(LocalShellChrome provides chrome) {
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.imePadding()
.padding(
horizontal = RecipeTheme.spacing.lg,
vertical = RecipeTheme.spacing.sm,
),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
if (ui.searchOpen && activeTab.hasSearch) {
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = true,
onTabSelect = { /* unreachable while collapsed */ },
onCollapsedTap = { closeActiveSearch() },
height = activeSearchHeight,
// Body — RootNavHost fills the available space and is the shared source
// layer for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
RootNavHost(
navController = navController,
modifier = Modifier.fillMaxSize(),
)
val placeholderRes = activeTab.searchPlaceholder
if (placeholderRes != null) {
val pillModifier = Modifier.weight(1f)
when (activeTab) {
BottomBarDestination.Recipes -> {
SearchPill(
query = recipesSearch.query,
onQueryChange = { recipesSearchVm.onQueryChange(it) },
onFocusChanged = { searchFieldFocused = it },
placeholder = stringResource(placeholderRes),
modifier = pillModifier,
height = activeSearchHeight,
)
}
}
BottomBarDestination.Pantry -> {
SearchPill(
query = pantrySearch.query,
onQueryChange = { pantrySearchVm.onQueryChange(it) },
onFocusChanged = { searchFieldFocused = it },
placeholder = stringResource(placeholderRes),
modifier = pillModifier,
height = activeSearchHeight,
)
}
else -> {
Box(modifier = pillModifier)
}
}
} else {
Box(modifier = Modifier.weight(1f))
}
if (searchFieldFocused) {
DismissSearchKeyboardButton(
onClick = { clearActiveSearchAndDismissKeyboard() },
size = activeSearchHeight,
)
}
} else {
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = false,
onTabSelect = { dest ->
navController.navigateToTab(dest.graphRoute)
vm.onTabChanged(dest)
// Bottom chrome — one Row, two layout modes (default / overlay) chosen
// by AnimatedContent for a clean cross-fade.
Row(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.imePadding()
.padding(
horizontal = RecipeTheme.spacing.lg,
vertical = RecipeTheme.spacing.sm,
),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
val overlay = chrome.bottomOverlay
AnimatedContent(
targetState = overlay != null,
modifier = Modifier.fillMaxWidth(),
transitionSpec = {
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
},
onCollapsedTap = { closeActiveSearch() },
modifier = Modifier.weight(1f),
height = dockHeight,
)
Box(modifier = Modifier.size(56.dp)) {
if (activeTab.hasSearch) {
FloatingSearchButton(
onClick = {
when (activeTab) {
BottomBarDestination.Recipes -> recipesSearchVm.open()
BottomBarDestination.Pantry -> pantrySearchVm.open()
else -> Unit
}
vm.openSearch()
},
label = "AppShell bottom chrome",
) { useOverlay ->
if (useOverlay) {
// Re-read current overlay inside this branch — chrome state
// can change after the targetState was captured.
chrome.bottomOverlay?.invoke()
} else {
DefaultDockRow(
activeTab = activeTab,
onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) },
trailingSlot = chrome.trailingSlot,
)
}
}
@@ -247,41 +151,37 @@ fun AppShell(modifier: Modifier = Modifier) {
}
}
/**
* Maps a [NavBackStackEntry]'s current route hierarchy to a [BottomBarDestination].
* Inspects the destination hierarchy for the parent graph route; CMP nav-compose
* 2.9.2 supports type-safe [hasRoute] matching against @Serializable graph types.
*/
@Composable
private fun DismissSearchKeyboardButton(
onClick: () -> Unit,
size: Dp,
private fun DefaultDockRow(
activeTab: BottomBarDestination,
onTabSelect: (BottomBarDestination) -> Unit,
trailingSlot: (@Composable () -> Unit)?,
) {
val a11y = stringResource(Res.string.search_dismiss_keyboard_a11y)
GlassSurface(
modifier = Modifier.size(size),
cornerRadius = size / 2,
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
UnstyledButton(
onClick = onClick,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = Lucide.X,
contentDescription = a11y,
tint = RecipeTheme.colors.content,
modifier = Modifier.size(24.dp),
)
}
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = false,
onTabSelect = onTabSelect,
onCollapsedTap = { /* unreachable in default mode */ },
modifier = Modifier.weight(1f),
height = 56.dp,
)
Box(modifier = Modifier.size(56.dp)) {
trailingSlot?.invoke()
}
}
}
/**
* Maps a [NavBackStackEntry]'s current route hierarchy to a [BottomBarDestination].
* CMP nav-compose 2.9.2 supports type-safe [hasRoute] matching against
* @Serializable graph types.
*/
private fun NavBackStackEntry?.toBottomBarDestination(): BottomBarDestination? {
if (this == null) return null
val hierarchy = destination.hierarchy

View File

@@ -0,0 +1,53 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Slot-based contract between [AppShell] and the currently-active feature screen.
*
* The shell is intentionally agnostic about *what* contextual chrome each tab needs.
* Instead, every screen is handed a [ShellChromeState] via [LocalShellChrome] and
* pushes its own trailing button / bottom overlay into it. The shell only renders
* the slots — it does not know about search, filters, or any other feature concept.
*
* Two slots are exposed:
*
* - [trailingSlot] — composable rendered in the 56dp slot to the right of the
* [DockBar] in default mode. `null` means the slot is empty (placeholder for
* future contextual buttons on tabs that don't currently use it).
*
* - [bottomOverlay] — when non-null, the shell renders this **instead of** the
* default `DockBar + trailingSlot` row. Features use this to take over the
* bottom chrome entirely (e.g. Recipes / Pantry rendering a collapsed dock +
* [SearchPill] + dismiss button while search is open).
*
* Lifecycle: every screen that writes to these slots **must** clear them in an
* `onDispose { ... }` block. The recommended pattern (see `ProvideSearchChrome`)
* uses a `===` identity guard so a late-running disposer can't clobber slots
* that the next screen has already taken ownership of.
*
* The state holder itself is created once in [AppShell] and provided down the tree
* via [staticCompositionLocalOf] — its identity never changes for the lifetime of
* the shell, so `staticCompositionLocalOf` (which skips dependency tracking and
* recomposes the whole subtree on change) is the right primitive here.
*/
@Stable
class ShellChromeState {
var trailingSlot: (@Composable () -> Unit)? by mutableStateOf(null)
var bottomOverlay: (@Composable () -> Unit)? by mutableStateOf(null)
}
/**
* Reads the [ShellChromeState] supplied by the nearest [AppShell] ancestor.
* Throws if no shell is in the composition — feature screens are always meant
* to render inside an [AppShell].
*/
val LocalShellChrome =
staticCompositionLocalOf<ShellChromeState> {
error("ShellChromeState not provided — wrap content in AppShell { ... }")
}

View File

@@ -1,60 +0,0 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.navigation.BottomBarDestination
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Immutable UI state for [AppShell]. The shell tracks two things:
* - [activeTab] which tab is currently selected (mirrors NavHost back-stack head).
* - [searchOpen] whether the search affordance is open (D-06: only valid when
* [activeTab].hasSearch is true).
*
* Query text deliberately lives in the active tab's SearchViewModel
* (RecipesSearchViewModel or PantrySearchViewModel from plan 02.1-06). This keeps
* Phase 5's extension hook connected to the UI that the user actually sees.
*/
data class ShellState(
val activeTab: BottomBarDestination = BottomBarDestination.Default,
val searchOpen: Boolean = false,
)
/**
* Active-tab + search state machine for the shell. Pure synchronous state
* transitions — no I/O, no viewModelScope.launch. Mirrors LoginViewModel's
* VM+StateFlow+method-per-action shape (CLAUDE.md project convention).
*
* Note: per-tab Search VMs (Recipes, Pantry — plan 02.1-06) own query and clear
* behavior. ShellViewModel mirrors search OPEN status here so the dock and floating
* button can react synchronously.
*/
class ShellViewModel : ViewModel() {
private val _state = MutableStateFlow(ShellState())
val state: StateFlow<ShellState> = _state.asStateFlow()
/** D-05 / D-06: open the search affordance on the active tab. No-op if the
* active tab has no search (defensive — UI is supposed to gate the call). */
fun openSearch() {
_state.update { current ->
if (!current.activeTab.hasSearch) {
current
} else {
current.copy(searchOpen = true)
}
}
}
/** D-08 shell half: closing hides search. AppShell also calls activeSearchVm.close(). */
fun closeSearch() {
_state.update { it.copy(searchOpen = false) }
}
/** Tab change — also closes any open search per D-08 (closing on tab switch is
* the same semantic: search state does not persist across tab switch). */
fun onTabChanged(dest: BottomBarDestination) {
_state.update { ShellState(activeTab = dest, searchOpen = false) }
}
}