Implement main app navigation
This commit is contained in:
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -43,8 +43,13 @@ class UserRepository(
|
||||
}
|
||||
}
|
||||
|
||||
AuthState.Unauthenticated -> _currentUser.value = null
|
||||
AuthState.Loading -> Unit
|
||||
AuthState.Unauthenticated -> {
|
||||
_currentUser.value = null
|
||||
}
|
||||
|
||||
AuthState.Loading -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user