Implement main app navigation

This commit is contained in:
2026-05-08 14:03:26 +02:00
parent f7e866a08d
commit 794e27c554
90 changed files with 11725 additions and 187 deletions

View File

@@ -0,0 +1,26 @@
package dev.ulfrx.recipe.ui.components.glass
import android.app.Application
import android.content.pm.ApplicationInfo
/**
* Android actual: this module does not expose an app `BuildConfig` class on the
* Kotlin compile classpath, so read the runtime debuggable flag from the current
* application instead. This keeps release builds on the production path without
* requiring a build-file change outside this plan's ownership.
*/
actual val isDebugBuild: Boolean
get() =
currentApplication()
?.applicationInfo
?.flags
?.and(ApplicationInfo.FLAG_DEBUGGABLE) != 0
@Suppress("PrivateApi")
private fun currentApplication(): Application? =
runCatching {
Class
.forName("android.app.ActivityThread")
.getMethod("currentApplication")
.invoke(null) as? Application
}.getOrNull()

View File

@@ -12,4 +12,30 @@
<string name="auth_error_cancelled">Logowanie anulowane. Spróbuj ponownie.</string>
<string name="auth_error_network">Nie można połączyć z Authentik. Sprawdź połączenie.</string>
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
<!-- Phase 2.1 — App shell navigation tab labels (UI-03, CONTEXT D-03) -->
<string name="shell_tab_planner">Planer</string>
<string name="shell_tab_recipes">Przepisy</string>
<string name="shell_tab_pantry">Spiżarnia</string>
<string name="shell_tab_shopping">Zakupy</string>
<!-- Phase 2.1 — Search affordance placeholders (UI-10, CONTEXT D-06) -->
<string name="search_placeholder_recipes">Szukaj przepisów…</string>
<string name="search_placeholder_pantry">Szukaj w spiżarni…</string>
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
<string name="search_clear_a11y">Wyczyść</string>
<!-- Phase 2.1 — Empty-state copy (UI-09, CONTEXT D-10/D-11/D-12) -->
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
<string name="empty_recipes_title">Tu pojawi się Twoja książka kucharska</string>
<string name="empty_recipes_subtitle">Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.</string>
<string name="empty_pantry_title">Spiżarnia jest jeszcze pusta</string>
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
</resources>

View File

@@ -9,14 +9,37 @@ import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.ui.screens.auth.LoginScreen
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
import dev.ulfrx.recipe.ui.screens.shell.AppShell
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import dev.ulfrx.recipe.user.UserRepository
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Pure routing decision for [App] — facilitates unit testing of the auth gate
* (V-04 in AppShellGateTest). Maps an [AuthState] + nullable currentUser to one
* of three top-level branches.
*/
enum class RootRoute { Splash, Login, Shell }
/**
* Pure helper — returned route is what [App] should render. Two-layer gate:
* [AuthSession] tells us whether tokens exist; [UserRepository] tells us who
* the authenticated principal is in the app's data model. While tokens are
* present but the `/me` fetch hasn't returned yet, we hold on splash so the
* user never sees an empty post-login screen.
*/
internal fun resolveRootRoute(
authState: AuthState,
hasCurrentUser: Boolean,
): RootRoute =
when (authState) {
AuthState.Loading -> RootRoute.Splash
AuthState.Unauthenticated -> RootRoute.Login
AuthState.Authenticated -> if (hasCurrentUser) RootRoute.Shell else RootRoute.Splash
}
/**
* Two-layer gate: [AuthSession] tells us whether tokens exist; [UserRepository]
* tells us who the authenticated principal is in the app's data model. While
@@ -40,22 +63,10 @@ fun App() {
authSession.initialize()
}
when (authState) {
AuthState.Loading -> SplashScreen()
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
AuthState.Authenticated -> {
val user = currentUser
if (user == null) {
SplashScreen()
} else {
PostLoginPlaceholderScreen(
user = user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
when (resolveRootRoute(authState, hasCurrentUser = currentUser != null)) {
RootRoute.Splash -> SplashScreen()
RootRoute.Login -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
RootRoute.Shell -> AppShell()
}
}
}

View File

@@ -10,7 +10,10 @@ interface OidcClientGateway {
suspend fun refresh(authStateJson: String): OidcResult
suspend fun logout(authStateJson: String, browser: AuthBrowser)
suspend fun logout(
authStateJson: String,
browser: AuthBrowser,
)
}
interface AuthStateStore {
@@ -52,7 +55,10 @@ class AuthSession(
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
override suspend fun logout(
authStateJson: String,
browser: AuthBrowser,
) {
oidcClient.logout(authStateJson, browser)
}
},

View File

@@ -28,8 +28,7 @@ internal fun Client.recipeAuthorizationCodeFlow(): AuthFlow =
),
)
internal fun Client.recipeEndSessionFlow(): AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal fun Client.recipeEndSessionFlow(): AuthFlow? = endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null

View File

@@ -22,11 +22,14 @@ class OidcClient(
val flow = client.recipeAuthorizationCodeFlow()
return when (val failure = browser.launchAndAwait(flow.prepare()).toOidcFailureOrNull()) {
null ->
null -> {
runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
}
else -> failure
else -> {
failure
}
}
}
@@ -39,7 +42,10 @@ class OidcClient(
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
}
suspend fun logout(authStateJson: String, browser: AuthBrowser) {
suspend fun logout(
authStateJson: String,
browser: AuthBrowser,
) {
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()

View File

@@ -13,7 +13,7 @@ import com.russhwolf.settings.Settings
*
* Platform [Settings] are wired in the platform Koin module:
* - Android: [com.russhwolf.settings.SharedPreferencesSettings]
* - iOS: [com.russhwolf.settings.KeychainSettings]
* - iOS: [com.russhwolf.settings.KeychainSettings]
*/
class SecureAuthStateStore(
private val settings: Settings,

View File

@@ -4,8 +4,9 @@ import dev.ulfrx.recipe.auth.authModule
import dev.ulfrx.recipe.user.userModule
import org.koin.dsl.module
// Phase 2 adds authModule + userModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
// Phase 2 adds authModule + userModule. Phase 2.1 adds shellModule (UI-03/04/09/10).
// Phase 4 will add syncModule; Phase 5 will add catalogModule; etc.
val appModule =
module {
includes(authModule, userModule)
includes(authModule, userModule, shellModule)
}

View File

@@ -0,0 +1,61 @@
package dev.ulfrx.recipe.di
import com.russhwolf.settings.Settings
import dev.ulfrx.recipe.ui.components.glass.GlassBackend
import dev.ulfrx.recipe.ui.components.glass.isDebugBuild
import dev.ulfrx.recipe.ui.components.glass.resolveGlassBackend
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
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
/**
* Phase 2.1 (UI-03 / UI-04 / UI-09 / UI-10) — DI module for the app-shell layer.
*
* Registers:
* - 4 tab ViewModels (Planner / Recipes / Pantry / Shopping) — pure StateFlow,
* no dependencies this phase. Phase 5+ extends each to inject repositories.
* - 2 Search ViewModels (Recipes + Pantry) — pure StateFlow with nullable
* `searchSource: SearchSource? = null` per RESEARCH § Pattern 4.
* - 1 ShellViewModel — active-tab + search-open state machine.
* - 1 GlassBackend single — resolved at composition root from
* [resolveGlassBackend] (CONTEXT D-16 / D-17). Default backend is
* [GlassBackend.Liquid] — the iOS+Android primary path; debug builds may
* pick up a runtime override stored in `multiplatform-settings`.
*
* Settings binding: registered in platform-specific Koin modules
* (`auth/IosAuthModule.kt`, `auth/AndroidAuthModule.kt`) for use by
* SecureAuthStateStore — the same single<Settings> binding is reused here.
*/
val shellModule =
module {
// Glass backend — resolved once at startup. Production builds short-circuit
// [resolveGlassBackend] via [isDebugBuild] = false; debug builds may pick up
// a runtime override stored in `multiplatform-settings`.
single<GlassBackend> {
resolveGlassBackend(
settings = get<Settings>(),
isDebug = isDebugBuild,
default = GlassBackend.Liquid,
)
}
// Shell-level state machine.
viewModel<ShellViewModel>()
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
viewModel<PlannerViewModel>()
viewModel<RecipesViewModel>()
viewModel<PantryViewModel>()
viewModel<ShoppingViewModel>()
// Per-tab Search ViewModels — pure echo this phase; Phase 5 / 8 inject
// their respective SearchSource implementations.
viewModel<RecipesSearchViewModel>()
viewModel<PantrySearchViewModel>()
}

View File

@@ -0,0 +1,68 @@
package dev.ulfrx.recipe.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.composables.icons.lucide.BookOpenText
import com.composables.icons.lucide.CalendarDays
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Package
import com.composables.icons.lucide.ShoppingCart
import org.jetbrains.compose.resources.StringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.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
import recipe.composeapp.generated.resources.shell_tab_shopping
/**
* The 4 bottom-bar destinations in left→right order per CONTEXT D-03:
* Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal listing
* order, which research confirmed is non-binding.
*
* `hasSearch` drives D-06: search affordance lives on Recipes + Pantry only.
* `searchPlaceholder` is non-null IFF `hasSearch` is true.
*/
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,
),
;
companion object {
/** Default landing tab — CONTEXT D-03. */
val Default: BottomBarDestination = Planner
}
}

View File

@@ -0,0 +1,28 @@
package dev.ulfrx.recipe.navigation
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
/**
* Multi-back-stack tab navigation per UI-03 + RESEARCH § Pattern 1 (lines 304-339).
*
* Applies the canonical four-flag incantation:
* - `popUpTo(graph.findStartDestination().id) { saveState = true }` — saves the
* current tab's stack so re-selecting the tab later restores it.
* - `launchSingleTop = true` — selecting an already-active tab does NOT push a
* duplicate onto the back stack.
* - `restoreState = true` — when the destination tab is re-selected, restore its
* saved state instead of recreating it. CRITICAL: without this flag, ViewModels
* are re-created on every reselection (RESEARCH § Pitfall B).
*
* @param graphRoute the @Serializable graph route (e.g. PlannerGraph, RecipesGraph)
*/
fun NavHostController.navigateToTab(graphRoute: Any) {
navigate(graphRoute) {
popUpTo(graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}

View File

@@ -0,0 +1,93 @@
package dev.ulfrx.recipe.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
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.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.compose.viewmodel.koinViewModel
/**
* Root of the app shell's navigation. Hosts ONE root [NavHost] containing four
* [navigation] sub-graphs (one per tab) so each tab preserves its own back stack
* independently across tab switches (RESEARCH § Pattern 1; UI-03).
*
* Default start destination: [PlannerGraph] per CONTEXT D-03.
*
* Per-tab ViewModel scoping: each composable<*Home> block retrieves the parent
* graph's [androidx.navigation.NavBackStackEntry] via
* `navController.getBackStackEntry(*Graph)` and passes it as `viewModelStoreOwner`
* to `koinViewModel(...)`. This makes per-tab VMs survive within the graph
* (RESEARCH § Pattern 2) — Phase 5 detail screens inherit cleanly.
*/
@Composable
fun RootNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = PlannerGraph,
modifier = modifier.fillMaxSize(),
) {
// ---- Planner graph (default landing — D-03) ----
navigation<PlannerGraph>(startDestination = PlannerHome) {
composable<PlannerHome> { entry ->
val parent =
remember(entry) {
navController.getBackStackEntry(PlannerGraph)
}
val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
PlannerScreen(viewModel = vm)
}
// future: composable<PlannerDetail>{ ... }
}
// ---- Recipes graph ----
navigation<RecipesGraph>(startDestination = RecipesHome) {
composable<RecipesHome> { entry ->
val parent =
remember(entry) {
navController.getBackStackEntry(RecipesGraph)
}
val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
RecipesScreen(viewModel = vm)
}
}
// ---- Pantry graph ----
navigation<PantryGraph>(startDestination = PantryHome) {
composable<PantryHome> { entry ->
val parent =
remember(entry) {
navController.getBackStackEntry(PantryGraph)
}
val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent)
PantryScreen(viewModel = vm)
}
}
// ---- Shopping graph ----
navigation<ShoppingGraph>(startDestination = ShoppingHome) {
composable<ShoppingHome> { entry ->
val parent =
remember(entry) {
navController.getBackStackEntry(ShoppingGraph)
}
val vm: ShoppingViewModel = koinViewModel(viewModelStoreOwner = parent)
ShoppingScreen(viewModel = vm)
}
}
}
}

View File

@@ -0,0 +1,33 @@
package dev.ulfrx.recipe.navigation
import kotlinx.serialization.Serializable
/**
* Type-safe route definitions for the 4-tab app shell (CONTEXT D-03).
* Each tab graph has a serializable route type and a home (start) destination.
* Phase 5+ extends each graph with detail destinations (RESEARCH § Pattern 1).
*/
@Serializable
data object PlannerGraph
@Serializable
data object PlannerHome
@Serializable
data object RecipesGraph
@Serializable
data object RecipesHome
@Serializable
data object PantryGraph
@Serializable
data object PantryHome
@Serializable
data object ShoppingGraph
@Serializable
data object ShoppingHome

View File

@@ -0,0 +1,137 @@
package dev.ulfrx.recipe.ui.components.controls
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.LoaderCircle
import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import com.composeunstyled.UnstyledProgressIndicator
import dev.ulfrx.recipe.ui.theme.RecipeTheme
@Composable
fun RecipePrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
) {
RecipeButtonFrame(
onClick = onClick,
enabled = enabled && !loading,
backgroundColor = if (enabled) RecipeTheme.colors.accent else RecipeTheme.colors.separator,
contentColor = RecipeTheme.colors.surface,
modifier = modifier,
) {
if (loading) {
RecipeLoadingIndicator(
size = 16.dp,
color = RecipeTheme.colors.surface,
)
} else {
BasicText(
text = text,
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.surface),
)
}
}
}
@Composable
fun RecipeOutlinedButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val contentColor = if (enabled) RecipeTheme.colors.content else RecipeTheme.colors.contentMuted
RecipeButtonFrame(
onClick = onClick,
enabled = enabled,
backgroundColor = Color.Transparent,
contentColor = contentColor,
borderColor = RecipeTheme.colors.separator,
modifier = modifier,
) {
BasicText(
text = text,
style = RecipeTheme.typography.label.copy(color = contentColor),
)
}
}
@Composable
fun RecipeLoadingIndicator(
modifier: Modifier = Modifier,
size: Dp = 24.dp,
color: Color = RecipeTheme.colors.accent,
) {
val transition = rememberInfiniteTransition(label = "RecipeLoadingIndicator")
val rotation =
transition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 900, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "loading icon rotation",
)
UnstyledProgressIndicator(
modifier = modifier.size(size),
contentColor = color,
) {
UnstyledIcon(
imageVector = Lucide.LoaderCircle,
contentDescription = null,
tint = color,
modifier =
Modifier
.size(size)
.rotate(rotation.value),
)
}
}
@Composable
private fun RecipeButtonFrame(
onClick: () -> Unit,
enabled: Boolean,
backgroundColor: Color,
contentColor: Color,
modifier: Modifier = Modifier,
borderColor: Color = Color.Unspecified,
content: @Composable RowScope.() -> Unit,
) {
UnstyledButton(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(24.dp),
backgroundColor = backgroundColor,
contentColor = contentColor,
borderColor = borderColor,
borderWidth = if (borderColor == Color.Unspecified) 0.dp else 1.dp,
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp),
modifier = modifier.defaultMinSize(minHeight = 48.dp),
content = content,
)
}

View File

@@ -0,0 +1,220 @@
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import com.composeunstyled.UnstyledTab
import com.composeunstyled.UnstyledTabGroup
import com.composeunstyled.UnstyledTabList
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_close_a11y
/**
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
*
* - Expanded (collapsed=false): all 4 tabs, icon + label always shown (D-02), active
* tab visually emphasized via accent foreground. Capsule shape: 28dp corner radius,
* 56dp height.
* - Collapsed (collapsed=true): single circular cell showing only the active tab's
* icon, no label. 22dp corner radius (full-pill at 44dp height). Tapping invokes
* [onCollapsedTap] which closes the search per D-05.
*
* Single coordinated animation per D-05: the dock animates as one block via
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
* [FastOutSlowInEasing] per UI-SPEC line 198.
*
* Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid/Haze API calls are
* forbidden here per CLAUDE.md non-negotiable #10.
*
* Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224).
*/
@Composable
fun DockBar(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
collapsed: Boolean,
onTabSelect: (BottomBarDestination) -> Unit,
onCollapsedTap: () -> Unit,
modifier: Modifier = Modifier,
height: androidx.compose.ui.unit.Dp = 56.dp,
) {
GlassSurface(
modifier =
if (collapsed) {
modifier.size(height)
} else {
modifier.height(height)
}.animateContentSize(
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
),
cornerRadius = height / 2,
) {
AnimatedContent(
targetState = collapsed,
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
transitionSpec = {
fadeIn(tween(durationMillis = 250, easing = FastOutSlowInEasing)) togetherWith
fadeOut(tween(durationMillis = 250, easing = FastOutSlowInEasing))
},
label = "DockBar collapse",
) { isCollapsed ->
if (isCollapsed) {
CollapsedDockToggle(
active = active,
onTap = onCollapsedTap,
size = height,
)
} else {
ExpandedDockTabs(
destinations = destinations,
active = active,
onTabSelect = onTabSelect,
)
}
}
}
}
@Composable
private fun ExpandedDockTabs(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
onTabSelect: (BottomBarDestination) -> Unit,
) {
UnstyledTabGroup(
selectedTab = active.name,
tabs = destinations.map { it.name },
modifier = Modifier.fillMaxSize(),
) {
UnstyledTabList(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = RecipeTheme.spacing.xs),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { dest ->
val isActive = dest == active
DockTabCell(
destination = dest,
isActive = isActive,
onClick = { onTabSelect(dest) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun DockTabCell(
destination: BottomBarDestination,
isActive: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
val pillColor = if (isActive) RecipeTheme.colors.accent.copy(alpha = 0.16f) else Color.Transparent
val labelText = stringResource(destination.labelRes)
val a11ySuffix = if (isActive) ", aktywna" else ""
UnstyledTab(
key = destination.name,
selected = isActive,
onSelected = onClick,
activateOnFocus = false,
shape = RoundedCornerShape(20.dp),
backgroundColor = pillColor,
contentPadding = PaddingValues(vertical = 6.dp),
modifier =
modifier
.fillMaxSize()
.semantics {
contentDescription = labelText + a11ySuffix
},
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
UnstyledIcon(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.size(2.dp))
BasicText(
text = labelText,
style = RecipeTheme.typography.label.copy(color = tint),
)
}
}
}
}
@Composable
private fun CollapsedDockToggle(
active: BottomBarDestination,
onTap: () -> Unit,
size: androidx.compose.ui.unit.Dp = 56.dp,
) {
val a11yLabel = stringResource(Res.string.search_close_a11y)
UnstyledButton(
onClick = onTap,
shape = RoundedCornerShape(size / 2),
backgroundColor = Color.Transparent,
contentPadding = PaddingValues(0.dp),
modifier =
Modifier
.size(size)
.clip(RoundedCornerShape(size / 2))
.semantics { contentDescription = a11yLabel },
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = active.icon,
contentDescription = null,
tint = RecipeTheme.colors.accent,
modifier = Modifier.size(24.dp),
)
}
}
}

View File

@@ -0,0 +1,59 @@
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_open_a11y
/**
* 44dp circular Liquid-glass button per UI-SPEC line 181.
*
* Visible only on Recipes + Pantry tabs (D-06 — gated by AppShell, not here).
* Hidden when search is open (also gated by AppShell — see AppShell.kt).
*
* Substrate: [GlassSurface] cornerRadius=22dp = full-circle at 44dp.
* Icon: Lucide search tinted [RecipeTheme.colors.content].
* Accessibility: contentDescription = stringResource(search_open_a11y) per UI-SPEC line 221.
*/
@Composable
fun FloatingSearchButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val a11y = stringResource(Res.string.search_open_a11y)
GlassSurface(
modifier = modifier.size(56.dp),
cornerRadius = 28.dp,
) {
UnstyledButton(
onClick = onClick,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = Lucide.Search,
contentDescription = a11y,
tint = RecipeTheme.colors.content,
modifier = Modifier.size(24.dp),
)
}
}
}
}

View File

@@ -0,0 +1,83 @@
package dev.ulfrx.recipe.ui.components.empty
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Reusable empty-state composable per CONTEXT D-13 / UI-SPEC line 183.
*
* Visual contract:
* - Centered Column on the screen.
* - 48dp icon tinted [RecipeTheme.colors.contentMuted] (calm, low-saturation per D-10).
* - 8dp gap (`sm`) between icon and headline.
* - Headline in [RecipeTheme.typography.display] color [RecipeTheme.colors.content].
* - 16dp gap (`lg`) between headline and subline.
* - Subline in [RecipeTheme.typography.body] color [RecipeTheme.colors.contentMuted].
* - Optional [action] slot below subline at 24dp gap (`xl`); unused this phase
* (D-12 — no CTAs in empty states), but the slot is reserved per D-13.
*
* Accessibility: column carries `Modifier.semantics(mergeDescendants = true)` so
* VoiceOver reads headline + subline as one announcement (UI-SPEC line 226).
*/
@Composable
fun EmptyState(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(horizontal = RecipeTheme.spacing.xl)
.semantics(mergeDescendants = true) {},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
UnstyledIcon(
imageVector = icon,
contentDescription = null,
tint = RecipeTheme.colors.contentMuted,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(RecipeTheme.spacing.sm))
BasicText(
text = title,
style =
RecipeTheme.typography.display.copy(
color = RecipeTheme.colors.content,
textAlign = TextAlign.Center,
),
)
Spacer(Modifier.height(RecipeTheme.spacing.lg))
BasicText(
text = subtitle,
style =
RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.contentMuted,
textAlign = TextAlign.Center,
),
)
if (action != null) {
Spacer(Modifier.height(RecipeTheme.spacing.xl))
action()
}
}
}

View File

@@ -0,0 +1,36 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
/**
* Flat translucent fallback with no blur. Geometry matches Liquid/Haze so
* chrome call sites never branch on the active backend.
*/
@Composable
internal fun FlatGlassSurface(
modifier: Modifier,
tint: Color,
cornerRadius: Dp,
border: BorderStroke?,
content: @Composable BoxScope.() -> Unit,
) {
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.background(tint, shape)
.let { if (border != null) it.border(border, shape) else it },
content = content,
)
}

View File

@@ -0,0 +1,54 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
/**
* Shared source/sampling state for glass chrome.
*
* AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
* consume [LocalGlassBackdropState] so Liquid/Haze sample the same layer behind
* the dock/search chrome.
*/
@Stable
class GlassBackdropState internal constructor(
internal val liquidState: Any,
internal val hazeState: Any,
)
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
@Composable
fun rememberGlassBackdropState(): GlassBackdropState {
val liquidState = rememberLiquidBackdropHandle()
val hazeState = rememberHazeBackdropHandle()
return remember(liquidState, hazeState) {
GlassBackdropState(
liquidState = liquidState,
hazeState = hazeState,
)
}
}
@Composable
fun GlassBackdropSource(
modifier: Modifier = Modifier,
state: GlassBackdropState = rememberGlassBackdropState(),
content: @Composable BoxScope.() -> Unit,
) {
CompositionLocalProvider(LocalGlassBackdropState provides state) {
Box(
modifier =
modifier
.liquidBackdropSource(state)
.hazeBackdropSource(state),
content = content,
)
}
}

View File

@@ -0,0 +1,46 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.runtime.compositionLocalOf
import com.russhwolf.settings.Settings
/**
* Three glass-effect backends per CONTEXT D-16. All three consume the same
* token API so chrome call sites never branch on the active backend.
*/
enum class GlassBackend {
Liquid,
Haze,
Flat,
}
/**
* Composition root sets this to the resolved backend for the running build.
* Consumers outside a provider fail safe to the simplest visible substrate.
*/
val LocalGlassBackend = compositionLocalOf { GlassBackend.Flat }
/**
* Debug-only runtime override key (D-17). Values: "liquid", "haze", "flat".
*/
const val DEBUG_GLASS_BACKEND_KEY: String = "debug.glass_backend"
/**
* Pure backend resolver used by production code and common tests.
*
* Release builds return [default] without consulting settings, so production
* binaries do not carry a runtime backend switch.
*/
fun resolveGlassBackend(
settings: Settings,
isDebug: Boolean,
default: GlassBackend,
): GlassBackend {
if (!isDebug) return default
val raw = settings.getStringOrNull(DEBUG_GLASS_BACKEND_KEY) ?: return default
return when (raw.lowercase()) {
"liquid" -> GlassBackend.Liquid
"haze" -> GlassBackend.Haze
"flat" -> GlassBackend.Flat
else -> default
}
}

View File

@@ -0,0 +1,31 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Single public entry point for glass-effect chrome. Dispatches to one backend
* through [LocalGlassBackend] and consumes the shared backdrop source when one
* is present above it.
*/
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
content: @Composable BoxScope.() -> Unit,
) {
val backdropState = LocalGlassBackdropState.current
when (LocalGlassBackend.current) {
GlassBackend.Liquid -> LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
GlassBackend.Haze -> HazeGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
GlassBackend.Flat -> FlatGlassSurface(modifier, tint, cornerRadius, border, content)
}
}

View File

@@ -0,0 +1,58 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
/**
* Haze 1.x backend per CONTEXT D-16. The actual 1.6.10 API takes a
* HazeStyle/block instead of a shape parameter, so shape is enforced by the
* surrounding clip while the effect consumes the shared [HazeState].
*/
@Composable
internal fun HazeGlassSurface(
modifier: Modifier,
tint: Color,
cornerRadius: Dp,
border: BorderStroke?,
backdropState: GlassBackdropState?,
content: @Composable BoxScope.() -> Unit,
) {
val state = backdropState?.hazeState as? HazeState ?: rememberHazeState()
val shape = RoundedCornerShape(cornerRadius)
val style =
HazeStyle(
backgroundColor = tint.copy(alpha = 1f),
tint = HazeTint(tint),
blurRadius = 24.dp,
)
Box(
modifier =
modifier
.clip(shape)
.hazeEffect(state, style)
.background(tint, shape)
.let { if (border != null) it.border(border, shape) else it },
content = content,
)
}
@Composable
internal fun rememberHazeBackdropHandle(): Any = rememberHazeState()
internal fun Modifier.hazeBackdropSource(state: GlassBackdropState): Modifier = hazeSource(state.hazeState as HazeState)

View File

@@ -0,0 +1,7 @@
package dev.ulfrx.recipe.ui.components.glass
/**
* Compile-time gate for the [resolveGlassBackend] runtime override path
* (CONTEXT D-17).
*/
expect val isDebugBuild: Boolean

View File

@@ -0,0 +1,53 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.liquid
import io.github.fletchmckee.liquid.rememberLiquidState
/**
* Liquid backend per CONTEXT D-16. The source layer is applied by
* [GlassBackdropSource] through [liquidBackdropSource], and chrome consumes the
* same [LiquidState] here.
*/
@Composable
internal fun LiquidGlassSurface(
modifier: Modifier,
tint: Color,
cornerRadius: Dp,
border: BorderStroke?,
backdropState: GlassBackdropState?,
content: @Composable BoxScope.() -> Unit,
) {
val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState()
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier =
modifier
.clip(shape)
.liquid(state) {
frost = 24.dp
this.shape = shape
this.tint = tint
}.background(tint, shape)
.let { if (border != null) it.border(border, shape) else it },
content = content,
)
}
@Composable
internal fun rememberLiquidBackdropHandle(): Any = rememberLiquidState()
internal fun Modifier.liquidBackdropSource(state: GlassBackdropState): Modifier = liquefiable(state.liquidState as LiquidState)

View File

@@ -0,0 +1,128 @@
package dev.ulfrx.recipe.ui.components.search
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Inline bottom search pill per CONTEXT D-09 + UI-SPEC line 182.
*
* Geometry: 44dp height, 22dp corner radius (full-pill at 44dp).
* Substrate: [GlassSurface] with [RecipeTheme.colors.surfaceGlass] tint.
*
* Layout (left → right):
* - Leading Lucide search icon, tinted [RecipeTheme.colors.contentMuted].
* - [BasicTextField] for query input (renderless — Material 3 forbidden in shell
* code per UI-SPEC line 31; Compose Unstyled `TextField` was the spec'd primitive
* but `BasicTextField` is a clean equivalent that ships with compose-foundation).
*
* Keyboard avoidance: `Modifier.imePadding()` is applied by the caller (AppShell —
* plan 02.1-05) at the chrome Column level, NOT here, to keep the pill geometry
* decoupled from inset handling.
*
* The text field itself is a standard BasicTextField, so its VoiceOver semantics
* work out of the box.
*/
@Composable
fun SearchPill(
query: String,
onQueryChange: (String) -> Unit,
onFocusChanged: (Boolean) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
height: Dp = 56.dp,
) {
GlassSurface(
modifier = modifier.height(height),
cornerRadius = height / 2,
) {
Row(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
UnstyledIcon(
imageVector = Lucide.Search,
contentDescription = null,
tint = RecipeTheme.colors.contentMuted,
modifier = Modifier.size(20.dp),
)
Box(
modifier =
Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = query,
onValueChange = onQueryChange,
textStyle = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
cursorBrush = SolidColor(RecipeTheme.colors.accent),
singleLine = true,
modifier =
Modifier
.fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) },
decorationBox = { innerField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
if (query.isEmpty()) {
PlaceholderText(
text = placeholder,
color = RecipeTheme.colors.contentMuted,
style = RecipeTheme.typography.body,
)
}
innerField()
}
},
)
}
}
}
}
/**
* Internal helper — placeholder text rendered when the BasicTextField is empty.
* Plain text in [RecipeTheme.typography.body] tinted [RecipeTheme.colors.contentMuted].
*/
@Composable
private fun PlaceholderText(
text: String,
color: Color,
style: TextStyle,
) {
BasicText(
text = text,
style = style.copy(color = color),
)
}

View File

@@ -1,5 +1,6 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -8,23 +9,19 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import dev.ulfrx.recipe.ui.components.controls.RecipePrimaryButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
@@ -41,9 +38,11 @@ fun LoginScreen(viewModel: LoginViewModel) {
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
Box(
modifier =
Modifier
.fillMaxSize()
.background(RecipeTheme.colors.surface),
) {
Column(
modifier =
@@ -54,38 +53,27 @@ fun LoginScreen(viewModel: LoginViewModel) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
BasicText(
text = stringResource(Res.string.auth_app_name),
style = MaterialTheme.typography.displaySmall,
style = RecipeTheme.typography.display.copy(color = RecipeTheme.colors.content),
)
Spacer(Modifier.height(24.dp))
Button(
RecipePrimaryButton(
text = stringResource(Res.string.auth_sign_in_button),
onClick = { viewModel.onSignInClick(browser) },
enabled = !state.isLoading,
) {
if (state.isLoading) {
Box(
modifier = Modifier.size(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
}
} else {
Text(text = stringResource(Res.string.auth_sign_in_button))
}
}
loading = state.isLoading,
)
val errorKey = state.errorKey
if (errorKey != null) {
Spacer(Modifier.height(16.dp))
Text(
BasicText(
text = stringResource(errorKey),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
style =
RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.destructive,
textAlign = TextAlign.Center,
),
)
}
}

View File

@@ -1,25 +1,26 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import dev.ulfrx.recipe.shared.dto.User
import dev.ulfrx.recipe.ui.components.controls.RecipeOutlinedButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_sign_out_button
@@ -35,9 +36,11 @@ fun PostLoginPlaceholderScreen(
) {
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
Box(
modifier =
Modifier
.fillMaxSize()
.background(RecipeTheme.colors.surface),
) {
Column(
modifier =
@@ -48,15 +51,19 @@ fun PostLoginPlaceholderScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
BasicText(
text = stringResource(Res.string.auth_welcome_format, user.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
style =
RecipeTheme.typography.title.copy(
color = RecipeTheme.colors.content,
textAlign = TextAlign.Center,
),
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { viewModel.onSignOutClick(browser) }) {
Text(text = stringResource(Res.string.auth_sign_out_button))
}
RecipeOutlinedButton(
text = stringResource(Res.string.auth_sign_out_button),
onClick = { viewModel.onSignOutClick(browser) },
)
}
}
}

View File

@@ -1,21 +1,22 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.components.controls.RecipeLoadingIndicator
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
@@ -28,9 +29,11 @@ import recipe.composeapp.generated.resources.auth_app_name
@Composable
@Preview
fun SplashScreen() {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
Box(
modifier =
Modifier
.fillMaxSize()
.background(RecipeTheme.colors.surface),
) {
Column(
modifier =
@@ -41,14 +44,12 @@ fun SplashScreen() {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
BasicText(
text = stringResource(Res.string.auth_app_name),
style = MaterialTheme.typography.displaySmall,
style = RecipeTheme.typography.display.copy(color = RecipeTheme.colors.content),
)
Spacer(Modifier.height(8.dp))
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
RecipeLoadingIndicator()
}
}
}

View File

@@ -0,0 +1,60 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.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.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.shell_tab_pantry
/**
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
* empty body with the inventory list.
*/
@Composable
fun PantryScreen(viewModel: PantryViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {
Column(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_pantry),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Pantry.icon,
title = stringResource(Res.string.empty_pantry_title),
subtitle = stringResource(Res.string.empty_pantry_subtitle),
)
}
}
}
}

View File

@@ -0,0 +1,43 @@
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 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).
*
* 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.
*/
class PantrySearchViewModel(
@Suppress("UNUSED_PARAMETER")
private val searchSource: SearchSource? = null,
) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
fun open() {
_state.update { it.copy(isOpen = true) }
}
/** D-08: closing clears the query. */
fun close() {
_state.value = SearchState(isOpen = false, query = "")
}
fun onQueryChange(q: String) {
_state.update { it.copy(query = q) }
}
/** D-07: clear() resets only the query, preserves isOpen. */
fun clear() {
_state.update { it.copy(query = "") }
}
}

View File

@@ -0,0 +1,19 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [PantryScreen]. Phase 2.1 ships only the empty state. Phase 8
* (Pantry) extends this with inventory rows + actions.
*/
data class PantryState(
val isEmpty: Boolean = true,
)
class PantryViewModel : ViewModel() {
private val _state = MutableStateFlow(PantryState())
val state: StateFlow<PantryState> = _state.asStateFlow()
}

View File

@@ -0,0 +1,63 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.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.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_planner_subtitle
import recipe.composeapp.generated.resources.empty_planner_title
import recipe.composeapp.generated.resources.shell_tab_planner
/**
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
* empty body with the calendar grid.
*/
@Composable
fun PlannerScreen(viewModel: PlannerViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier =
Modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
Column(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Planner.icon,
title = stringResource(Res.string.empty_planner_title),
subtitle = stringResource(Res.string.empty_planner_subtitle),
)
}
}
}
}

View File

@@ -0,0 +1,19 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [PlannerScreen]. Phase 2.1 ships only the empty state. Phase 6
* (Meal Planner — Core Write Path) extends this with calendar data + actions.
*/
data class PlannerState(
val isEmpty: Boolean = true,
)
class PlannerViewModel : ViewModel() {
private val _state = MutableStateFlow(PlannerState())
val state: StateFlow<PlannerState> = _state.asStateFlow()
}

View File

@@ -0,0 +1,60 @@
package dev.ulfrx.recipe.ui.screens.recipes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.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.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_recipes_subtitle
import recipe.composeapp.generated.resources.empty_recipes_title
import recipe.composeapp.generated.resources.shell_tab_recipes
/**
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
* empty body with the recipe catalog grid.
*/
@Composable
fun RecipesScreen(viewModel: RecipesViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {
Column(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_recipes),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Recipes.icon,
title = stringResource(Res.string.empty_recipes_title),
subtitle = stringResource(Res.string.empty_recipes_subtitle),
)
}
}
}
}

View File

@@ -0,0 +1,68 @@
package dev.ulfrx.recipe.ui.screens.recipes
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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()) }`.
*/
class RecipesSearchViewModel(
@Suppress("UNUSED_PARAMETER")
private val searchSource: SearchSource? = null,
) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
/** Open the search affordance. */
fun open() {
_state.update { it.copy(isOpen = true) }
}
/** D-08: closing clears the query — reopening starts blank. */
fun close() {
_state.value = SearchState(isOpen = false, query = "")
}
/** Query echo. Phase 5 will plumb `searchSource.observe(...)` here. */
fun onQueryChange(q: String) {
_state.update { it.copy(query = q) }
}
/** D-07: clear() resets only the query and keeps isOpen=true. */
fun clear() {
_state.update { it.copy(query = "") }
}
}

View File

@@ -0,0 +1,19 @@
package dev.ulfrx.recipe.ui.screens.recipes
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [RecipesScreen]. Phase 2.1 ships only the empty state. Phase 5
* (Recipe Catalog Read Path) extends this with `recipes` etc.
*/
data class RecipesState(
val isEmpty: Boolean = true,
)
class RecipesViewModel : ViewModel() {
private val _state = MutableStateFlow(RecipesState())
val state: StateFlow<RecipesState> = _state.asStateFlow()
}

View File

@@ -0,0 +1,295 @@
package dev.ulfrx.recipe.ui.screens.shell
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
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.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
import dev.ulfrx.recipe.navigation.RecipesGraph
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).
*
* Layout responsibilities:
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
* - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource]
* so Liquid/Haze chrome sample 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).
*
* 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.
*/
@Preview
@Composable
fun AppShell(modifier: Modifier = Modifier) {
val navController = rememberNavController()
val backStack by navController.currentBackStackEntryAsState()
val activeTab =
remember(backStack) {
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
fun closeActiveSearch() {
when (activeTab) {
BottomBarDestination.Recipes -> recipesSearchVm.close()
BottomBarDestination.Pantry -> pantrySearchVm.close()
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()
}
}
Box(
modifier =
modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
// Body — RootNavHost fills the available space and is the shared source layer
// for Liquid/Haze 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(
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,
) {
if (ui.searchOpen && activeTab.hasSearch) {
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = true,
onTabSelect = { /* unreachable while collapsed */ },
onCollapsedTap = { closeActiveSearch() },
height = activeSearchHeight,
)
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)
},
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()
},
)
}
}
}
}
}
}
/**
* 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,
) {
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? {
if (this == null) return null
val hierarchy = destination.hierarchy
return when {
hierarchy.any { it.hasRoute(PlannerGraph::class) } -> BottomBarDestination.Planner
hierarchy.any { it.hasRoute(RecipesGraph::class) } -> BottomBarDestination.Recipes
hierarchy.any { it.hasRoute(PantryGraph::class) } -> BottomBarDestination.Pantry
hierarchy.any { it.hasRoute(ShoppingGraph::class) } -> BottomBarDestination.Shopping
else -> null
}
}

View File

@@ -0,0 +1,60 @@
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) }
}
}

View File

@@ -0,0 +1,60 @@
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.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.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_shopping_subtitle
import recipe.composeapp.generated.resources.empty_shopping_title
import recipe.composeapp.generated.resources.shell_tab_shopping
/**
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
* empty body with the shopping list + session UI.
*/
@Composable
fun ShoppingScreen(viewModel: ShoppingViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {
Column(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_shopping),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Shopping.icon,
title = stringResource(Res.string.empty_shopping_title),
subtitle = stringResource(Res.string.empty_shopping_subtitle),
)
}
}
}
}

View File

@@ -0,0 +1,19 @@
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [ShoppingScreen]. Phase 2.1 ships only the empty state. Phase 9
* (Shopping List & Session Log) extends this with list items + session actions.
*/
data class ShoppingState(
val isEmpty: Boolean = true,
)
class ShoppingViewModel : ViewModel() {
private val _state = MutableStateFlow(ShoppingState())
val state: StateFlow<ShoppingState> = _state.asStateFlow()
}

View File

@@ -0,0 +1,45 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Semantic color tokens (UI-SPEC § Color, CONTEXT D-14, D-15).
* Values are locked; do not introduce raw hex in screen code.
*/
public data class RecipeColors(
val background: Color,
val surface: Color,
val surfaceGlass: Color,
val content: Color,
val contentMuted: Color,
val accent: Color,
val separator: Color,
val borderCard: Color,
val destructive: Color,
)
public val LightRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFFF7F5F1),
surface = Color(0xFFFFFFFF),
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.60f),
content = Color(0xFF0F1113),
contentMuted = Color(0xFF6B6E73),
accent = Color(0xFFD97757),
separator = Color(0xFFE5E1DA),
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
destructive = Color(0xFFC0392B),
)
public val DarkRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFF0F1113),
surface = Color(0xFF1A1D21),
surfaceGlass = Color(0xFF1A1D21).copy(alpha = 0.55f),
content = Color(0xFFF1EFEA),
contentMuted = Color(0xFF9AA0A6),
accent = Color(0xFFE48A6E),
separator = Color(0xFF2A2D31),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
destructive = Color(0xFFE57368),
)

View File

@@ -0,0 +1,28 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Glass surface defaults (UI-SPEC § Glass / Layout).
* Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
* floating button (plan 02.1-05).
*/
public data class RecipeGlass(
val borderWidth: Dp,
val shadowOffsetY: Dp,
val shadowBlur: Dp,
val shadowAlphaLight: Float,
val shadowAlphaDark: Float,
val blurRadius: Dp,
)
public val DefaultRecipeGlass: RecipeGlass =
RecipeGlass(
borderWidth = 1.dp,
shadowOffsetY = 8.dp,
shadowBlur = 24.dp,
shadowAlphaLight = 0.12f,
shadowAlphaDark = 0.0f,
blurRadius = 24.dp,
)

View File

@@ -0,0 +1,22 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Shape tokens (UI-SPEC § Glass — corner radii for chrome elements).
*/
public data class RecipeShapes(
val dockExpanded: Dp,
val dockCollapsed: Dp,
val searchPill: Dp,
val floatingButton: Dp,
)
public val DefaultRecipeShapes: RecipeShapes =
RecipeShapes(
dockExpanded = 28.dp,
dockCollapsed = 22.dp,
searchPill = 22.dp,
floatingButton = 22.dp,
)

View File

@@ -0,0 +1,29 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Spacing scale (UI-SPEC § Spacing rev 1: 4 / 8 / 16 / 24 / 32 / 48).
* `xxl` and `xxxl` map to UI-SPEC's `2xl` / `3xl` because Kotlin identifiers
* cannot start with a digit. Tokens are referenced by these property names
* in screen code; UI-SPEC token names (`2xl`/`3xl`) are the documented contract.
*/
public data class RecipeSpacing(
val xs: Dp,
val sm: Dp,
val lg: Dp,
val xl: Dp,
val xxl: Dp,
val xxxl: Dp,
)
public val DefaultRecipeSpacing: RecipeSpacing =
RecipeSpacing(
xs = 4.dp,
sm = 8.dp,
lg = 16.dp,
xl = 24.dp,
xxl = 32.dp,
xxxl = 48.dp,
)

View File

@@ -1,35 +1,71 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.ReadOnlyComposable
import dev.ulfrx.recipe.ui.components.glass.GlassBackend
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackend
import org.koin.compose.koinInject
/**
* Phase 2 seed theme. Material 3 light/dark schemes with a single seed override on `primary`
* (`#3B6939` light / `#A2D597` dark — see `02-UI-SPEC.md` § Color). All other roles use
* Material 3 baseline values. Phase 11 may rebase the palette around a different seed.
* Recipe theme entry point (CONTEXT D-14, D-15).
*
* Intentionally minimal: no Haze, no custom typography, no shapes. Per UI-SPEC, Material 3
* defaults satisfy Phase 2's spacing/typography/accessibility contract.
* All app UI reads `RecipeTheme.colors.*`, `RecipeTheme.typography.*`,
* `RecipeTheme.spacing.*`, and local Recipe components built on Compose
* Unstyled. Material 3 is deliberately absent from the composition.
*/
private val LightColors =
lightColorScheme(
primary = Color(0xFF3B6939),
)
public val LocalRecipeColors: ProvidableCompositionLocal<RecipeColors> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeColors accessed outside RecipeTheme { }") }
private val DarkColors =
darkColorScheme(
primary = Color(0xFFA2D597),
)
public val LocalRecipeTypography: ProvidableCompositionLocal<RecipeTypography> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeTypography accessed outside RecipeTheme { }") }
public val LocalRecipeSpacing: ProvidableCompositionLocal<RecipeSpacing> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeSpacing accessed outside RecipeTheme { }") }
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") }
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
public fun RecipeTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
val glassBackend = koinInject<GlassBackend>()
CompositionLocalProvider(
LocalRecipeColors provides recipeColors,
LocalRecipeTypography provides DefaultRecipeTypography,
LocalRecipeSpacing provides DefaultRecipeSpacing,
LocalRecipeShapes provides DefaultRecipeShapes,
LocalRecipeGlass provides DefaultRecipeGlass,
LocalGlassBackend provides glassBackend,
content = content,
)
}
public object RecipeTheme {
public val colors: RecipeColors
@Composable @ReadOnlyComposable
get() = LocalRecipeColors.current
public val typography: RecipeTypography
@Composable @ReadOnlyComposable
get() = LocalRecipeTypography.current
public val spacing: RecipeSpacing
@Composable @ReadOnlyComposable
get() = LocalRecipeSpacing.current
public val shapes: RecipeShapes
@Composable @ReadOnlyComposable
get() = LocalRecipeShapes.current
public val glass: RecipeGlass
@Composable @ReadOnlyComposable
get() = LocalRecipeGlass.current
}

View File

@@ -0,0 +1,53 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* Typography tokens (UI-SPEC § Typography). System default font family
* (SF Pro on iOS, Roboto on Android) for v1.
*/
public data class RecipeTypography(
val display: TextStyle,
val title: TextStyle,
val body: TextStyle,
val label: TextStyle,
)
public val DefaultRecipeTypography: RecipeTypography =
RecipeTypography(
display =
TextStyle(
fontFamily = FontFamily.Default,
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 34.sp,
letterSpacing = (-0.2).sp,
),
title =
TextStyle(
fontFamily = FontFamily.Default,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
body =
TextStyle(
fontFamily = FontFamily.Default,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
label =
TextStyle(
fontFamily = FontFamily.Default,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 16.sp,
letterSpacing = 0.1.sp,
),
)

View File

@@ -43,8 +43,13 @@ class UserRepository(
}
}
AuthState.Unauthenticated -> _currentUser.value = null
AuthState.Loading -> Unit
AuthState.Unauthenticated -> {
_currentUser.value = null
}
AuthState.Loading -> {
Unit
}
}
}
}

View File

@@ -200,7 +200,10 @@ class AuthSessionTest {
return refreshResult
}
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
override suspend fun logout(
authStateJson: String,
browser: AuthBrowser,
) {
logoutCalls += authStateJson
if (logoutThrows) {
error("end-session failed")

View File

@@ -12,31 +12,95 @@ private class InMemorySettings : Settings {
override val size: Int get() = map.size
override fun clear() = map.clear()
override fun remove(key: String) { map.remove(key) }
override fun remove(key: String) {
map.remove(key)
}
override fun hasKey(key: String): Boolean = map.containsKey(key)
override fun putInt(key: String, value: Int) { map[key] = value }
override fun getInt(key: String, defaultValue: Int): Int = (map[key] as? Int) ?: defaultValue
override fun putInt(
key: String,
value: Int,
) {
map[key] = value
}
override fun getInt(
key: String,
defaultValue: Int,
): Int = (map[key] as? Int) ?: defaultValue
override fun getIntOrNull(key: String): Int? = map[key] as? Int
override fun putLong(key: String, value: Long) { map[key] = value }
override fun getLong(key: String, defaultValue: Long): Long = (map[key] as? Long) ?: defaultValue
override fun putLong(
key: String,
value: Long,
) {
map[key] = value
}
override fun getLong(
key: String,
defaultValue: Long,
): Long = (map[key] as? Long) ?: defaultValue
override fun getLongOrNull(key: String): Long? = map[key] as? Long
override fun putString(key: String, value: String) { map[key] = value }
override fun getString(key: String, defaultValue: String): String = (map[key] as? String) ?: defaultValue
override fun putString(
key: String,
value: String,
) {
map[key] = value
}
override fun getString(
key: String,
defaultValue: String,
): String = (map[key] as? String) ?: defaultValue
override fun getStringOrNull(key: String): String? = map[key] as? String
override fun putFloat(key: String, value: Float) { map[key] = value }
override fun getFloat(key: String, defaultValue: Float): Float = (map[key] as? Float) ?: defaultValue
override fun putFloat(
key: String,
value: Float,
) {
map[key] = value
}
override fun getFloat(
key: String,
defaultValue: Float,
): Float = (map[key] as? Float) ?: defaultValue
override fun getFloatOrNull(key: String): Float? = map[key] as? Float
override fun putDouble(key: String, value: Double) { map[key] = value }
override fun getDouble(key: String, defaultValue: Double): Double = (map[key] as? Double) ?: defaultValue
override fun putDouble(
key: String,
value: Double,
) {
map[key] = value
}
override fun getDouble(
key: String,
defaultValue: Double,
): Double = (map[key] as? Double) ?: defaultValue
override fun getDoubleOrNull(key: String): Double? = map[key] as? Double
override fun putBoolean(key: String, value: Boolean) { map[key] = value }
override fun getBoolean(key: String, defaultValue: Boolean): Boolean = (map[key] as? Boolean) ?: defaultValue
override fun putBoolean(
key: String,
value: Boolean,
) {
map[key] = value
}
override fun getBoolean(
key: String,
defaultValue: Boolean,
): Boolean = (map[key] as? Boolean) ?: defaultValue
override fun getBooleanOrNull(key: String): Boolean? = map[key] as? Boolean
}

View File

@@ -0,0 +1,62 @@
package dev.ulfrx.recipe.navigation
import androidx.navigation.NavHostController
import androidx.navigation.navOptions
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* V-01 — UI-03 — `navigateToTab()` extension applies the four-flag multi-back-stack
* incantation:
* popUpTo(graph.findStartDestination().id) { saveState = true }
* launchSingleTop = true
* restoreState = true
*
* Strategy: the public NavHostController.navigateToTab call cannot be unit-tested
* without a live NavHostController (which requires a Compose composition runtime
* not available in pure commonTest). So we test the LAMBDA SHAPE that
* navigateToTab passes to navigate(...): we replicate the production lambda body
* against the official `navOptions { ... }` builder and assert the resulting
* NavOptions properties via the public `shouldXxx()` accessors.
*/
class NavigationTest {
@Test
fun navigateToTab_lambda_setsLaunchSingleTopAndRestoreState() {
val opts =
navOptions {
popUpTo(0) { saveState = true }
launchSingleTop = true
restoreState = true
}
assertTrue(opts.shouldLaunchSingleTop(), "launchSingleTop must be true")
assertTrue(opts.shouldRestoreState(), "restoreState must be true")
assertTrue(opts.shouldPopUpToSaveState(), "popUpTo { saveState = true } must be set")
}
@Test
fun navigateToTab_extension_isPublicAndDefinedOnNavHostController() {
// Compile-time + reflection-light assertion: the function exists with the
// expected signature. If it disappears or its signature drifts, the test
// file no longer compiles, which itself is a failed test.
val fn: (NavHostController, Any) -> Unit = { c, route -> c.navigateToTab(route) }
assertNotNull(fn)
}
@Test
fun navigateToTab_lambda_setsAllFourFlagsTogether() {
// Belt-and-suspenders: a single test that the four flags fire together,
// not individually — UI-03 hard-coded contract.
val opts =
navOptions {
popUpTo(42) { saveState = true }
launchSingleTop = true
restoreState = true
}
assertEquals(true, opts.shouldLaunchSingleTop())
assertEquals(true, opts.shouldRestoreState())
assertEquals(true, opts.shouldPopUpToSaveState())
}
}

View File

@@ -0,0 +1,85 @@
package dev.ulfrx.recipe.ui.components.glass
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* V-03 - UI-04 - debug-build runtime override via multiplatform-settings
* honors "debug.glass_backend" values. Production builds ignore overrides.
*/
class GlassBackendOverrideTest {
@Test
fun resolveGlassBackend_debugBuildHonorsHazeOverride() {
val settings = MapSettings()
settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze")
val result =
resolveGlassBackend(
settings = settings,
isDebug = true,
default = GlassBackend.Liquid,
)
assertEquals(GlassBackend.Haze, result)
}
@Test
fun resolveGlassBackend_debugBuildHonorsFlatOverride() {
val settings = MapSettings()
settings.putString(DEBUG_GLASS_BACKEND_KEY, "flat")
val result =
resolveGlassBackend(
settings = settings,
isDebug = true,
default = GlassBackend.Liquid,
)
assertEquals(GlassBackend.Flat, result)
}
@Test
fun resolveGlassBackend_debugBuildHonorsLiquidOverride() {
val settings = MapSettings()
settings.putString(DEBUG_GLASS_BACKEND_KEY, "liquid")
val result =
resolveGlassBackend(
settings = settings,
isDebug = true,
default = GlassBackend.Haze,
)
assertEquals(GlassBackend.Liquid, result)
}
@Test
fun resolveGlassBackend_caseInsensitive() {
val settings = MapSettings()
settings.putString(DEBUG_GLASS_BACKEND_KEY, "HAZE")
val result =
resolveGlassBackend(
settings = settings,
isDebug = true,
default = GlassBackend.Liquid,
)
assertEquals(GlassBackend.Haze, result)
}
@Test
fun resolveGlassBackend_productionBuildIgnoresOverride() {
val settings = MapSettings()
settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze")
val result =
resolveGlassBackend(
settings = settings,
isDebug = false,
default = GlassBackend.Liquid,
)
assertEquals(GlassBackend.Liquid, result)
}
}

View File

@@ -0,0 +1,152 @@
package dev.ulfrx.recipe.ui.components.glass
import com.russhwolf.settings.Settings
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* V-02 - UI-04 - resolveGlassBackend(...) returns the compile-time default
* when no debug override is present.
*/
class GlassBackendTest {
@Test
fun resolveGlassBackend_iosDefault_returnsLiquid() {
val result =
resolveGlassBackend(
settings = MapSettings(),
isDebug = false,
default = GlassBackend.Liquid,
)
assertEquals(GlassBackend.Liquid, result)
}
@Test
fun resolveGlassBackend_emptySettings_returnsDefault() {
val result =
resolveGlassBackend(
settings = MapSettings(),
isDebug = true,
default = GlassBackend.Liquid,
)
assertEquals(GlassBackend.Liquid, result)
}
@Test
fun resolveGlassBackend_unknownOverride_returnsDefault() {
val settings = MapSettings()
settings.putString(DEBUG_GLASS_BACKEND_KEY, "neon-wave")
val result =
resolveGlassBackend(
settings = settings,
isDebug = true,
default = GlassBackend.Liquid,
)
assertEquals(GlassBackend.Liquid, result)
}
}
internal class MapSettings : Settings {
private val values = mutableMapOf<String, Any>()
override val keys: Set<String>
get() = values.keys
override val size: Int
get() = values.size
override fun clear() {
values.clear()
}
override fun remove(key: String) {
values.remove(key)
}
override fun hasKey(key: String): Boolean = key in values
override fun putInt(
key: String,
value: Int,
) {
values[key] = value
}
override fun getInt(
key: String,
defaultValue: Int,
): Int = getIntOrNull(key) ?: defaultValue
override fun getIntOrNull(key: String): Int? = values[key] as? Int
override fun putLong(
key: String,
value: Long,
) {
values[key] = value
}
override fun getLong(
key: String,
defaultValue: Long,
): Long = getLongOrNull(key) ?: defaultValue
override fun getLongOrNull(key: String): Long? = values[key] as? Long
override fun putString(
key: String,
value: String,
) {
values[key] = value
}
override fun getString(
key: String,
defaultValue: String,
): String = getStringOrNull(key) ?: defaultValue
override fun getStringOrNull(key: String): String? = values[key] as? String
override fun putFloat(
key: String,
value: Float,
) {
values[key] = value
}
override fun getFloat(
key: String,
defaultValue: Float,
): Float = getFloatOrNull(key) ?: defaultValue
override fun getFloatOrNull(key: String): Float? = values[key] as? Float
override fun putDouble(
key: String,
value: Double,
) {
values[key] = value
}
override fun getDouble(
key: String,
defaultValue: Double,
): Double = getDoubleOrNull(key) ?: defaultValue
override fun getDoubleOrNull(key: String): Double? = values[key] as? Double
override fun putBoolean(
key: String,
value: Boolean,
) {
values[key] = value
}
override fun getBoolean(
key: String,
defaultValue: Boolean,
): Boolean = getBooleanOrNull(key) ?: defaultValue
override fun getBooleanOrNull(key: String): Boolean? = values[key] as? Boolean
}

View File

@@ -91,7 +91,10 @@ class LoginViewModelTest {
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
override suspend fun logout(
authStateJson: String,
browser: AuthBrowser,
) {}
}
val session = AuthSession(oidc, FakeAuthStateStore())
val viewModel = LoginViewModel(session)
@@ -151,6 +154,9 @@ class LoginViewModelTest {
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
override suspend fun logout(
authStateJson: String,
browser: AuthBrowser,
) {}
}
}

View File

@@ -0,0 +1,42 @@
package dev.ulfrx.recipe.ui.screens.pantry
import dev.ulfrx.recipe.ui.screens.recipes.SearchState
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* V-07 — UI-10 — PantrySearchViewModel parity with RecipesSearchViewModel
* (open / close / clear semantics — CONTEXT D-07 / D-08).
*/
class PantrySearchViewModelTest {
@Test
fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() =
runTest {
val vm = PantrySearchViewModel()
vm.open()
vm.onQueryChange("mleko")
assertEquals(SearchState(isOpen = true, query = "mleko"), vm.state.value)
vm.close()
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
}
@Test
fun clear_resetsQueryButKeepsIsOpenTrue() =
runTest {
val vm = PantrySearchViewModel()
vm.open()
vm.onQueryChange("mleko")
vm.clear()
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
}
@Test
fun open_setsIsOpenTrueWithoutTouchingQuery() =
runTest {
val vm = PantrySearchViewModel()
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
vm.open()
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
}
}

View File

@@ -0,0 +1,62 @@
package dev.ulfrx.recipe.ui.screens.recipes
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* V-05 + V-06 — UI-10 — RecipesSearchViewModel state-machine semantics
* (RESEARCH § Pattern 4 + CONTEXT D-07 / D-08).
*
* V-05: open() → onQueryChange("foo") → close() leaves SearchState(isOpen=false, query="").
* V-06: clear() resets only query, keeps isOpen=true.
*/
class RecipesSearchViewModelTest {
@Test
fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() =
runTest {
val vm = RecipesSearchViewModel()
vm.open()
vm.onQueryChange("foo")
assertEquals(SearchState(isOpen = true, query = "foo"), vm.state.value)
vm.close()
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
}
@Test
fun clear_resetsQueryButKeepsIsOpenTrue() =
runTest {
val vm = RecipesSearchViewModel()
vm.open()
vm.onQueryChange("foo")
vm.clear()
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
}
@Test
fun open_setsIsOpenTrueWithoutTouchingQuery() =
runTest {
val vm = RecipesSearchViewModel()
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
vm.open()
assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
}
@Test
fun onQueryChange_doesNotAffectIsOpen() =
runTest {
val vm = RecipesSearchViewModel()
vm.onQueryChange("foo")
assertEquals(SearchState(isOpen = false, query = "foo"), vm.state.value)
}
@Test
fun closeFromAlreadyClosed_isIdempotent() =
runTest {
val vm = RecipesSearchViewModel()
vm.close()
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
vm.close()
assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
}
}

View File

@@ -0,0 +1,73 @@
package dev.ulfrx.recipe.ui.screens.shell
import dev.ulfrx.recipe.RootRoute
import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.resolveRootRoute
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* V-04 — UI-09 — App.kt's `Authenticated + currentUser != null` branch resolves
* to the AppShell route, not PostLoginPlaceholderScreen.
*
* Tested via the pure [resolveRootRoute] helper extracted in plan 02.1-08, so
* the routing semantics are deterministic without instrumenting a real Compose
* composition. (The CMP iOS Compose UI testing surface is too immature this
* phase for snapshot/UI tests on the actual `App()` composable —
* VALIDATION.md line 27.)
*/
class AppShellGateTest {
@Test
fun authenticatedWithUser_routesToShell_notPlaceholder() {
val route =
resolveRootRoute(
authState = AuthState.Authenticated,
hasCurrentUser = true,
)
assertEquals(RootRoute.Shell, route)
}
@Test
fun authenticatedWithoutUserYet_routesToSplash() {
// Two-layer gate per App.kt docstring: tokens present but /me has not
// returned yet → hold on splash, never show empty post-login.
val route =
resolveRootRoute(
authState = AuthState.Authenticated,
hasCurrentUser = false,
)
assertEquals(RootRoute.Splash, route)
}
@Test
fun unauthenticated_routesToLogin() {
val route =
resolveRootRoute(
authState = AuthState.Unauthenticated,
hasCurrentUser = false,
)
assertEquals(RootRoute.Login, route)
}
@Test
fun loadingAuth_routesToSplash() {
val route =
resolveRootRoute(
authState = AuthState.Loading,
hasCurrentUser = false,
)
assertEquals(RootRoute.Splash, route)
}
@Test
fun loadingAuthIgnoresHasCurrentUser() {
// Defensive: while Loading, we should always splash regardless of
// whether a stale currentUser is observable from a previous session.
val route =
resolveRootRoute(
authState = AuthState.Loading,
hasCurrentUser = true,
)
assertEquals(RootRoute.Splash, route)
}
}

View File

@@ -25,7 +25,10 @@ class UserRepositoryTest {
val repository =
UserRepository(
authSession = session,
fetchUser = { fetchCount++; USER },
fetchUser = {
fetchCount++
USER
},
scope = TestScope(testScheduler),
)
@@ -105,7 +108,10 @@ class UserRepositoryTest {
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
override suspend fun logout(
authStateJson: String,
browser: AuthBrowser,
) {}
}
private companion object {

View File

@@ -0,0 +1,8 @@
package dev.ulfrx.recipe.ui.components.glass
/**
* iOS actual: Kotlin/Native exposes whether the current binary was compiled
* for a debug configuration.
*/
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
actual val isDebugBuild: Boolean = kotlin.native.Platform.isDebugBinary