Reorganize dock and search
This commit is contained in:
@@ -68,7 +68,7 @@ fun App() {
|
|||||||
// RootRoute.Login -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
|
// RootRoute.Login -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
|
||||||
// RootRoute.Shell -> AppShell()
|
// RootRoute.Shell -> AppShell()
|
||||||
// }
|
// }
|
||||||
//for easier tests authentication is turned off
|
// for easier tests authentication is turned off
|
||||||
AppShell()
|
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.planner.PlannerViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
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 dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.plugin.module.dsl.viewModel
|
import org.koin.plugin.module.dsl.viewModel
|
||||||
|
|
||||||
val shellModule =
|
val shellModule =
|
||||||
module {
|
module {
|
||||||
// Shell-level state machine.
|
|
||||||
viewModel<ShellViewModel>()
|
|
||||||
|
|
||||||
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
|
// 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<PlannerViewModel>()
|
||||||
viewModel<RecipesViewModel>()
|
viewModel<RecipesViewModel>()
|
||||||
viewModel<PantryViewModel>()
|
viewModel<PantryViewModel>()
|
||||||
viewModel<ShoppingViewModel>()
|
viewModel<ShoppingViewModel>()
|
||||||
|
|
||||||
// Per-tab Search ViewModels — pure echo this phase; Phase 5 / 8 inject
|
// 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<RecipesSearchViewModel>()
|
||||||
viewModel<PantrySearchViewModel>()
|
viewModel<PantrySearchViewModel>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import com.composables.icons.lucide.Package
|
|||||||
import com.composables.icons.lucide.ShoppingCart
|
import com.composables.icons.lucide.ShoppingCart
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.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_pantry
|
||||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
import recipe.composeapp.generated.resources.shell_tab_recipes
|
||||||
@@ -21,43 +19,35 @@ import recipe.composeapp.generated.resources.shell_tab_shopping
|
|||||||
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal listing
|
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal listing
|
||||||
* order, which research confirmed is non-binding.
|
* order, which research confirmed is non-binding.
|
||||||
*
|
*
|
||||||
* `hasSearch` drives D-06: search affordance lives on Recipes + Pantry only.
|
* Per-tab contextual chrome (search button on Recipes / Pantry, future filter
|
||||||
* `searchPlaceholder` is non-null IFF `hasSearch` is true.
|
* 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(
|
enum class BottomBarDestination(
|
||||||
val graphRoute: Any,
|
val graphRoute: Any,
|
||||||
val labelRes: StringResource,
|
val labelRes: StringResource,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
val hasSearch: Boolean,
|
|
||||||
val searchPlaceholder: StringResource?,
|
|
||||||
) {
|
) {
|
||||||
Planner(
|
Planner(
|
||||||
graphRoute = PlannerGraph,
|
graphRoute = PlannerGraph,
|
||||||
labelRes = Res.string.shell_tab_planner,
|
labelRes = Res.string.shell_tab_planner,
|
||||||
icon = Lucide.CalendarDays,
|
icon = Lucide.CalendarDays,
|
||||||
hasSearch = false,
|
|
||||||
searchPlaceholder = null,
|
|
||||||
),
|
),
|
||||||
Recipes(
|
Recipes(
|
||||||
graphRoute = RecipesGraph,
|
graphRoute = RecipesGraph,
|
||||||
labelRes = Res.string.shell_tab_recipes,
|
labelRes = Res.string.shell_tab_recipes,
|
||||||
icon = Lucide.BookOpenText,
|
icon = Lucide.BookOpenText,
|
||||||
hasSearch = true,
|
|
||||||
searchPlaceholder = Res.string.search_placeholder_recipes,
|
|
||||||
),
|
),
|
||||||
Pantry(
|
Pantry(
|
||||||
graphRoute = PantryGraph,
|
graphRoute = PantryGraph,
|
||||||
labelRes = Res.string.shell_tab_pantry,
|
labelRes = Res.string.shell_tab_pantry,
|
||||||
icon = Lucide.Package,
|
icon = Lucide.Package,
|
||||||
hasSearch = true,
|
|
||||||
searchPlaceholder = Res.string.search_placeholder_pantry,
|
|
||||||
),
|
),
|
||||||
Shopping(
|
Shopping(
|
||||||
graphRoute = ShoppingGraph,
|
graphRoute = ShoppingGraph,
|
||||||
labelRes = Res.string.shell_tab_shopping,
|
labelRes = Res.string.shell_tab_shopping,
|
||||||
icon = Lucide.ShoppingCart,
|
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.composable
|
||||||
import androidx.navigation.compose.navigation
|
import androidx.navigation.compose.navigation
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
|
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.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
|
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
|
||||||
|
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
@@ -62,7 +64,8 @@ fun RootNavHost(
|
|||||||
navController.getBackStackEntry(RecipesGraph)
|
navController.getBackStackEntry(RecipesGraph)
|
||||||
}
|
}
|
||||||
val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
|
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)
|
navController.getBackStackEntry(PantryGraph)
|
||||||
}
|
}
|
||||||
val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent)
|
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()
|
val liquidState = rememberLiquidBackdropHandle()
|
||||||
return remember(liquidState) {
|
return remember(liquidState) {
|
||||||
GlassBackdropState(
|
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
|
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.empty_pantry_subtitle
|
import recipe.composeapp.generated.resources.empty_pantry_subtitle
|
||||||
import recipe.composeapp.generated.resources.empty_pantry_title
|
import recipe.composeapp.generated.resources.empty_pantry_title
|
||||||
|
import recipe.composeapp.generated.resources.search_placeholder_pantry
|
||||||
import recipe.composeapp.generated.resources.shell_tab_pantry
|
import recipe.composeapp.generated.resources.shell_tab_pantry
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
|
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
|
||||||
* empty body with the inventory list.
|
* 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
|
@Composable
|
||||||
fun PantryScreen(viewModel: PantryViewModel) {
|
fun PantryScreen(
|
||||||
|
viewModel: PantryViewModel,
|
||||||
|
searchViewModel: PantrySearchViewModel,
|
||||||
|
) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
ProvideSearchChrome(
|
||||||
|
controls = searchViewModel,
|
||||||
|
placeholder = Res.string.search_placeholder_pantry,
|
||||||
|
activeTab = BottomBarDestination.Pantry,
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.pantry
|
package dev.ulfrx.recipe.ui.screens.pantry
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.SearchSource
|
import dev.ulfrx.recipe.ui.components.search.SearchControls
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.SearchState
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PantrySearchViewModel — semantic parity with [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel].
|
* PantrySearchViewModel — semantic parity with
|
||||||
* Both VMs share [SearchState] and [SearchSource] from `ui.screens.recipes` (the
|
* [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel]. Both VMs share
|
||||||
* canonical home for the search-state shape).
|
* [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.
|
* 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.
|
* Constructor parameter has a default so Koin can register without a source today.
|
||||||
@@ -19,25 +21,26 @@ import kotlinx.coroutines.flow.update
|
|||||||
class PantrySearchViewModel(
|
class PantrySearchViewModel(
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
private val searchSource: SearchSource? = null,
|
private val searchSource: SearchSource? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel(),
|
||||||
|
SearchControls {
|
||||||
private val _state = MutableStateFlow(SearchState())
|
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) }
|
_state.update { it.copy(isOpen = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** D-08: closing clears the query. */
|
/** D-08: closing clears the query. */
|
||||||
fun close() {
|
override fun close() {
|
||||||
_state.value = SearchState(isOpen = false, query = "")
|
_state.value = SearchState(isOpen = false, query = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onQueryChange(q: String) {
|
override fun onQueryChange(q: String) {
|
||||||
_state.update { it.copy(query = q) }
|
_state.update { it.copy(query = q) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** D-07: clear() resets only the query, preserves isOpen. */
|
/** D-07: clear() resets only the query, preserves isOpen. */
|
||||||
fun clear() {
|
override fun clear() {
|
||||||
_state.update { it.copy(query = "") }
|
_state.update { it.copy(query = "") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,22 +16,39 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
|
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.empty_recipes_subtitle
|
import recipe.composeapp.generated.resources.empty_recipes_subtitle
|
||||||
import recipe.composeapp.generated.resources.empty_recipes_title
|
import recipe.composeapp.generated.resources.empty_recipes_title
|
||||||
|
import recipe.composeapp.generated.resources.search_placeholder_recipes
|
||||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
import recipe.composeapp.generated.resources.shell_tab_recipes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
|
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
|
||||||
* empty body with the recipe catalog grid.
|
* 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
|
@Composable
|
||||||
fun RecipesScreen(viewModel: RecipesViewModel) {
|
fun RecipesScreen(
|
||||||
|
viewModel: RecipesViewModel,
|
||||||
|
searchViewModel: RecipesSearchViewModel,
|
||||||
|
) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
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(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,68 +1,50 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.recipes
|
package dev.ulfrx.recipe.ui.screens.recipes
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.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
|
* RecipesSearchViewModel per RESEARCH § Pattern 4. Pure state machine; no I/O
|
||||||
* this phase (the [searchSource] parameter is the Phase 5 extension hook —
|
* this phase (the [searchSource] parameter is the Phase 5 extension hook —
|
||||||
* RESEARCH line 410). Constructor parameter has a default so Koin can register
|
* RESEARCH line 410). Constructor parameter has a default so Koin can register
|
||||||
* with `viewModel { RecipesSearchViewModel() }` and Phase 5 swaps to
|
* with `viewModel { RecipesSearchViewModel() }` and Phase 5 swaps to
|
||||||
* `viewModel { RecipesSearchViewModel(searchSource = get()) }`.
|
* `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(
|
class RecipesSearchViewModel(
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
private val searchSource: SearchSource? = null,
|
private val searchSource: SearchSource? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel(),
|
||||||
|
SearchControls {
|
||||||
private val _state = MutableStateFlow(SearchState())
|
private val _state = MutableStateFlow(SearchState())
|
||||||
val state: StateFlow<SearchState> = _state.asStateFlow()
|
override val state: StateFlow<SearchState> = _state.asStateFlow()
|
||||||
|
|
||||||
/** Open the search affordance. */
|
/** Open the search affordance. */
|
||||||
fun open() {
|
override fun open() {
|
||||||
_state.update { it.copy(isOpen = true) }
|
_state.update { it.copy(isOpen = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** D-08: closing clears the query — reopening starts blank. */
|
/** D-08: closing clears the query — reopening starts blank. */
|
||||||
fun close() {
|
override fun close() {
|
||||||
_state.value = SearchState(isOpen = false, query = "")
|
_state.value = SearchState(isOpen = false, query = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Query echo. Phase 5 will plumb `searchSource.observe(...)` here. */
|
/** Query echo. Phase 5 will plumb `searchSource.observe(...)` here. */
|
||||||
fun onQueryChange(q: String) {
|
override fun onQueryChange(q: String) {
|
||||||
_state.update { it.copy(query = q) }
|
_state.update { it.copy(query = q) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** D-07: clear() resets only the query and keeps isOpen=true. */
|
/** D-07: clear() resets only the query and keeps isOpen=true. */
|
||||||
fun clear() {
|
override fun clear() {
|
||||||
_state.update { it.copy(query = "") }
|
_state.update { it.copy(query = "") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.shell
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.navigation.PantryGraph
|
import dev.ulfrx.recipe.navigation.PantryGraph
|
||||||
import dev.ulfrx.recipe.navigation.PlannerGraph
|
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.ShoppingGraph
|
||||||
import dev.ulfrx.recipe.navigation.navigateToTab
|
import dev.ulfrx.recipe.navigation.navigateToTab
|
||||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||||
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||||
import dev.ulfrx.recipe.ui.components.glass.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 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.
|
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
|
||||||
* - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource]
|
* - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource]
|
||||||
* so Liquid chrome samples the screen body through [LocalGlassBackdropState].
|
* so Liquid chrome samples the screen body through `LocalGlassBackdropState`.
|
||||||
* - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill]
|
* - Bottom chrome (overlay): the active screen contributes its own contextual
|
||||||
* (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible).
|
* chrome via [ShellChromeState] / [LocalShellChrome] (see [ShellChrome.kt]).
|
||||||
* Chrome consumes [WindowInsets.navigationBars] + [imePadding] explicitly per
|
* AppShell renders one of two modes:
|
||||||
* Pitfall F — does NOT use safeContentPadding() at this layer.
|
* * `bottomOverlay` non-null → render the screen-supplied overlay full-width.
|
||||||
* - [FloatingSearchButton] aligned [Alignment.BottomEnd], visible only when
|
* * `bottomOverlay` null → render the default [DockBar] + a 56dp trailing
|
||||||
* !ui.searchOpen && active.hasSearch (D-06).
|
* 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
|
* ## Active-tab tracking
|
||||||
* hierarchy via [hasRoute]. The shell's [ShellViewModel] mirrors active tab so chrome
|
* Derived from the NavHost's current back stack entry hierarchy via [hasRoute].
|
||||||
* can react synchronously even before NavHost navigation completes.
|
* 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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
@@ -83,61 +83,24 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
backStack?.toBottomBarDestination() ?: BottomBarDestination.Default
|
backStack?.toBottomBarDestination() ?: BottomBarDestination.Default
|
||||||
}
|
}
|
||||||
|
|
||||||
val vm: ShellViewModel = koinViewModel()
|
// Single chrome-state holder for the lifetime of the shell. Provided to
|
||||||
val ui by vm.state.collectAsStateWithLifecycle()
|
// descendants via LocalShellChrome.
|
||||||
val recipesSearchVm: RecipesSearchViewModel = koinViewModel()
|
val chrome = remember { ShellChromeState() }
|
||||||
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
|
|
||||||
|
|
||||||
fun closeActiveSearch() {
|
// NB: we deliberately do NOT clear chrome on activeTab changes. The active
|
||||||
when (activeTab) {
|
// screen's `DisposableEffect` cleanup (in `ProvideSearchChrome` or similar)
|
||||||
BottomBarDestination.Recipes -> recipesSearchVm.close()
|
// is responsible for releasing its slots when it leaves composition. Doing
|
||||||
BottomBarDestination.Pantry -> pantrySearchVm.close()
|
// a redundant clear here would race with the new screen's setup.
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
vm.closeSearch()
|
|
||||||
searchFieldFocused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalShellChrome provides chrome) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(RecipeTheme.colors.background),
|
.background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
// Body — RootNavHost fills the available space and is the shared source layer
|
// Body — RootNavHost fills the available space and is the shared source
|
||||||
// for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
|
// layer for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
|
||||||
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
||||||
RootNavHost(
|
RootNavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -145,14 +108,8 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom chrome overlay — single Row spanning the full width with two
|
// Bottom chrome — one Row, two layout modes (default / overlay) chosen
|
||||||
// layout modes:
|
// by AnimatedContent for a clean cross-fade.
|
||||||
// - 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(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -167,121 +124,64 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (ui.searchOpen && activeTab.hasSearch) {
|
val overlay = chrome.bottomOverlay
|
||||||
DockBar(
|
AnimatedContent(
|
||||||
destinations = BottomBarDestination.entries,
|
targetState = overlay != null,
|
||||||
active = activeTab,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
collapsed = true,
|
transitionSpec = {
|
||||||
onTabSelect = { /* unreachable while collapsed */ },
|
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
||||||
onCollapsedTap = { closeActiveSearch() },
|
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
|
||||||
height = activeSearchHeight,
|
},
|
||||||
)
|
label = "AppShell bottom chrome",
|
||||||
val placeholderRes = activeTab.searchPlaceholder
|
) { useOverlay ->
|
||||||
if (placeholderRes != null) {
|
if (useOverlay) {
|
||||||
val pillModifier = Modifier.weight(1f)
|
// Re-read current overlay inside this branch — chrome state
|
||||||
when (activeTab) {
|
// can change after the targetState was captured.
|
||||||
BottomBarDestination.Recipes -> {
|
chrome.bottomOverlay?.invoke()
|
||||||
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 {
|
} else {
|
||||||
Box(modifier = Modifier.weight(1f))
|
DefaultDockRow(
|
||||||
}
|
activeTab = activeTab,
|
||||||
if (searchFieldFocused) {
|
onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) },
|
||||||
DismissSearchKeyboardButton(
|
trailingSlot = chrome.trailingSlot,
|
||||||
onClick = { clearActiveSearchAndDismissKeyboard() },
|
|
||||||
size = activeSearchHeight,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DefaultDockRow(
|
||||||
|
activeTab: BottomBarDestination,
|
||||||
|
onTabSelect: (BottomBarDestination) -> Unit,
|
||||||
|
trailingSlot: (@Composable () -> Unit)?,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
DockBar(
|
DockBar(
|
||||||
destinations = BottomBarDestination.entries,
|
destinations = BottomBarDestination.entries,
|
||||||
active = activeTab,
|
active = activeTab,
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onTabSelect = { dest ->
|
onTabSelect = onTabSelect,
|
||||||
navController.navigateToTab(dest.graphRoute)
|
onCollapsedTap = { /* unreachable in default mode */ },
|
||||||
vm.onTabChanged(dest)
|
|
||||||
},
|
|
||||||
onCollapsedTap = { closeActiveSearch() },
|
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
height = dockHeight,
|
height = 56.dp,
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.size(56.dp)) {
|
Box(modifier = Modifier.size(56.dp)) {
|
||||||
if (activeTab.hasSearch) {
|
trailingSlot?.invoke()
|
||||||
FloatingSearchButton(
|
|
||||||
onClick = {
|
|
||||||
when (activeTab) {
|
|
||||||
BottomBarDestination.Recipes -> recipesSearchVm.open()
|
|
||||||
BottomBarDestination.Pantry -> pantrySearchVm.open()
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
vm.openSearch()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a [NavBackStackEntry]'s current route hierarchy to a [BottomBarDestination].
|
* Maps a [NavBackStackEntry]'s current route hierarchy to a [BottomBarDestination].
|
||||||
* Inspects the destination hierarchy for the parent graph route; CMP nav-compose
|
* CMP nav-compose 2.9.2 supports type-safe [hasRoute] matching against
|
||||||
* 2.9.2 supports type-safe [hasRoute] matching against @Serializable graph types.
|
* @Serializable graph types.
|
||||||
*/
|
*/
|
||||||
@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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun NavBackStackEntry?.toBottomBarDestination(): BottomBarDestination? {
|
private fun NavBackStackEntry?.toBottomBarDestination(): BottomBarDestination? {
|
||||||
if (this == null) return null
|
if (this == null) return null
|
||||||
val hierarchy = destination.hierarchy
|
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