Reorganize dock and search
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,4 +57,3 @@ fun FloatingSearchButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ fun rememberGlassBackdropState(): GlassBackdropState {
|
||||
val liquidState = rememberLiquidBackdropHandle()
|
||||
return remember(liquidState) {
|
||||
GlassBackdropState(
|
||||
liquidState = liquidState
|
||||
liquidState = liquidState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { ... }")
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user