33 KiB
Phase 2.1: App Shell, Navigation & Search Foundation — Pattern Map
Mapped: 2026-05-08 Files analyzed: ~28 new + 3 modified Analogs found: 18 with strong analog / 13 greenfield (no in-repo analog yet — first occurrence of theme tokens, glass primitive, navigation graph)
File Classification
Modified files
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt |
app entry / auth gate router | reactive state → composition switch | self (extend) | self-modify |
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt |
Koin app aggregator | DI wiring | self (extend includes(...) list) |
self-modify |
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt |
theme entry / CompositionLocal scaffold | reactive (system dark mode) → token provision | self (rewrite — currently a thin Material 3 wrapper) | self-rewrite (preserve MaterialTheme(...) call so legacy auth screens keep working) |
composeApp/src/commonMain/composeResources/values/strings.xml |
resource bundle | static lookup | self (extend with shell_*/empty_*/search_* keys) |
self-modify |
gradle/libs.versions.toml |
version catalog | static config | self (extend) | self-modify |
composeApp/build.gradle.kts |
Gradle config | static config | self (extend commonMain.dependencies) |
self-modify |
New files — Navigation
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
navigation/Routes.kt |
route definitions | static @Serializable types |
none in repo | greenfield — first nav graph |
navigation/BottomBarDestination.kt |
tab enum binding routes ↔ resources ↔ icons | static config | none in repo (AuthState.kt is the only enum-style sealed type) |
greenfield |
navigation/RootNavHost.kt |
nested NavHost host | composition tree | none in repo | greenfield (RESEARCH.md § Pattern 1 + Code Example 1 lock the API) |
New files — Theme tokens
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
ui/theme/RecipeColors.kt |
semantic color tokens (light/dark) | static data class + selection | RecipeTheme.kt (LightColors/DarkColors private vals) |
partial-match (extend pattern from 2 colors → 9 semantic roles) |
ui/theme/RecipeTypography.kt |
typography tokens | static data class | none — no typography file exists yet | greenfield |
ui/theme/RecipeSpacing.kt |
spacing tokens | static data class | none | greenfield |
ui/theme/RecipeShapes.kt |
shape tokens (pill/circle radii) | static data class | none | greenfield |
ui/theme/RecipeGlass.kt |
glass-surface token defaults | static data class | none | greenfield |
New files — Glass primitive
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
ui/components/glass/GlassSurface.kt |
layered chrome substrate primitive | composition (backend dispatch) | none in repo | greenfield (RESEARCH.md § Pattern 3 locks the API) |
ui/components/glass/GlassBackend.kt |
enum + LocalGlassBackend |
static + CompositionLocal | none | greenfield |
ui/components/glass/LiquidGlassSurface.kt |
Liquid backend impl | composition | none — first Liquid use | greenfield |
ui/components/glass/HazeGlassSurface.kt |
Haze backend impl | composition | none — first Haze use | greenfield |
ui/components/glass/FlatGlassSurface.kt |
flat translucent fallback | composition | none | greenfield |
New files — Shell + chrome composables
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
ui/screens/shell/AppShell.kt |
authenticated root composable | reactive (StateFlow → composition) | LoginScreen.kt + App.kt |
partial-match (consumes koinViewModel, observes StateFlow.collectAsStateWithLifecycle()) |
ui/screens/shell/ShellViewModel.kt |
active-tab + search-open state machine | StateFlow + method-per-action | LoginViewModel.kt, PostLoginViewModel.kt |
exact (same VM+StateFlow+method-per-action shape) |
ui/components/dock/DockBar.kt |
floating pill with 4 tabs + collapse-on-search | composition + Modifier.animateContentSize |
none — first Compose Unstyled TabGroup consumer |
greenfield |
ui/components/dock/FloatingSearchButton.kt |
adjacent floating circular icon button | composition | none — first Compose Unstyled Button consumer |
greenfield |
ui/components/search/SearchPill.kt |
inline bottom search pill (renderless TextField) | composition + StateFlow input echo | LoginScreen.kt (TextField + button styling pattern, but Material 3) |
role-match (gleaned from auth screen layout style only — input semantics are new) |
ui/components/empty/EmptyState.kt |
reusable empty-state composable | static composition | LoginScreen.kt Column-Center pattern |
role-match (same Column / Arrangement.Center / horizontalAlignment skeleton) |
New files — Tab screens & ViewModels
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
ui/screens/planner/PlannerScreen.kt |
tab body screen | reactive | PostLoginPlaceholderScreen.kt |
exact (same Surface { Column { Text(stringResource(...)) } } skeleton, but rebuilt on RecipeTheme instead of MaterialTheme) |
ui/screens/planner/PlannerViewModel.kt |
screen VM | StateFlow + method-per-action | LoginViewModel.kt |
exact |
ui/screens/recipes/RecipesScreen.kt |
tab body screen | reactive | PostLoginPlaceholderScreen.kt |
exact |
ui/screens/recipes/RecipesViewModel.kt |
screen VM | StateFlow | LoginViewModel.kt |
exact |
ui/screens/recipes/RecipesSearchViewModel.kt |
search state machine | StateFlow + method-per-action | LoginViewModel.kt |
exact (shape mirrors; semantics from RESEARCH.md § Pattern 4) |
ui/screens/pantry/PantryScreen.kt |
tab body screen | reactive | PostLoginPlaceholderScreen.kt |
exact |
ui/screens/pantry/PantryViewModel.kt |
screen VM | StateFlow | LoginViewModel.kt |
exact |
ui/screens/pantry/PantrySearchViewModel.kt |
search state machine | StateFlow | LoginViewModel.kt |
exact |
ui/screens/shopping/ShoppingScreen.kt |
tab body screen | reactive | PostLoginPlaceholderScreen.kt |
exact |
ui/screens/shopping/ShoppingViewModel.kt |
screen VM | StateFlow | LoginViewModel.kt |
exact |
New files — DI
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
di/ShellModule.kt (or rolled into AppModule) |
Koin module — VMs + glass backend factory | DI wiring | auth/AuthModule.kt, user/UserModule.kt |
exact |
New files — Tests
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
commonTest/.../navigation/NavigationTest.kt |
nav extension unit test | pure function assertion | LoginViewModelTest.kt |
role-match (same kotlin.test + runTest skeleton; subject under test is a NavOptions builder lambda) |
commonTest/.../ui/components/glass/GlassBackendTest.kt |
backend selection unit test | pure | LoginViewModelTest.kt |
role-match |
commonTest/.../ui/components/glass/GlassBackendOverrideTest.kt |
debug-toggle test using MapSettings |
pure | LoginViewModelTest.kt (fakes pattern) |
role-match |
commonTest/.../ui/screens/shell/AppShellGateTest.kt |
App.kt routing assertion | reactive | AuthSessionTest.kt |
exact (shape: runTest + state-flow observation + assert branches) |
commonTest/.../ui/screens/recipes/RecipesSearchViewModelTest.kt |
search VM unit test | StateFlow assertion | LoginViewModelTest.kt |
exact |
commonTest/.../ui/screens/pantry/PantrySearchViewModelTest.kt |
search VM unit test | StateFlow assertion | LoginViewModelTest.kt |
exact |
Pattern Assignments
App.kt (modified — auth gate router)
Analog: self — current App.kt:43-58 has the when (authState) switch and the Authenticated + currentUser two-layer gate.
Pattern to preserve (App.kt:43-58):
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>(),
)
}
}
}
Modification: replace the PostLoginPlaceholderScreen(...) call with AppShell() (which internally hosts RootNavHost and consumes its own koinViewModel<ShellViewModel>()). The currentUser == null → SplashScreen() arm stays. Do NOT change the LaunchedEffect(authSession) { initialize() } block (App.kt:39-41) — still load-bearing. Do NOT delete PostLoginPlaceholderScreen / PostLoginViewModel yet — RESEARCH.md § Open Question 3 + CONTEXT line 101 keep them as a logout-bridge possibility; if unused after wiring, retire them in a separate task with explicit confirmation.
RecipeTheme.kt (rewritten — theme entry + CompositionLocal scaffold)
Analog: self — current shape (lines 18-35) is the structural template; the body is rewritten.
Pattern to extend (current RecipeTheme.kt:18-35):
private val LightColors = lightColorScheme(primary = Color(0xFF3B6939))
private val DarkColors = darkColorScheme(primary = Color(0xFFA2D597))
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(colorScheme = colors, content = content)
}
New shape (RESEARCH.md § Pattern 3 + UI-SPEC § Color/Typography/Spacing/Glass):
- Keep
MaterialTheme(colorScheme = ..., content = ...)wrapping the inner block so legacy auth screens (LoginScreen.kt:46,LoginScreen.kt:59—MaterialTheme.colorScheme.surface,MaterialTheme.typography.displaySmall) keep resolving (Open Question 3, recommended resolution). - Inside the
MaterialTheme { ... }, wrap aCompositionLocalProvider(LocalRecipeColors provides ..., LocalRecipeTypography provides ..., LocalRecipeSpacing provides ..., LocalRecipeShapes provides ..., LocalRecipeGlass provides ..., LocalGlassBackend provides ...) { content() }. - Public read site:
RecipeTheme.colors,RecipeTheme.typography,RecipeTheme.spacing,RecipeTheme.shapes,RecipeTheme.glass— implement as acompanion object-styleobject RecipeTheme { val colors: RecipeColors @Composable @ReadOnlyComposable get() = LocalRecipeColors.current ... }per the standard MaterialTheme idiom.
Color values: UI-SPEC § Color (lines 84-92) — verbatim hex. No mockup port.
ui/screens/shell/ShellViewModel.kt (new — VM analog: LoginViewModel)
Analog: ui/screens/auth/LoginViewModel.kt:37-55
State + method-per-action pattern (LoginViewModel.kt:37-55):
class LoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
fun onSignInClick(browser: AuthBrowser): Job {
_state.value = LoginScreenState(isLoading = true, errorKey = null)
return viewModelScope.launch {
val result = authSession.login(browser)
_state.value = LoginScreenState(isLoading = false, errorKey = result.toErrorKeyOrNull())
}
}
}
Apply to ShellViewModel:
data class ShellState(val activeTab: BottomBarDestination, val searchOpen: Boolean = false, val query: String = "")— single source of truth.private val _state = MutableStateFlow(ShellState(activeTab = BottomBarDestination.Planner)); exposestate: StateFlow<ShellState> = _state.asStateFlow().- Method-per-action:
fun openSearch(),fun closeSearch()(D-08: clears query),fun onQueryChange(q: String),fun clearQuery(),fun onTabChanged(dest: BottomBarDestination). - No
viewModelScope.launchneeded — pure synchronous state updates (no I/O this phase).
Same pattern for PlannerViewModel, RecipesViewModel, PantryViewModel, ShoppingViewModel, RecipesSearchViewModel, PantrySearchViewModel. The two *SearchViewModels use data class SearchState(val isOpen: Boolean = false, val query: String = "") per RESEARCH.md § Pattern 4 (lines 395-405). Phase 5 extension hook: leave a nullable searchSource: SearchSource? = null constructor param — RESEARCH.md line 410.
ui/screens/shell/AppShell.kt (new — composable analog: LoginScreen)
Analog: ui/screens/auth/LoginScreen.kt:39-93 for the shape (Composable observing a VM + collectAsStateWithLifecycle); the actual layout follows RESEARCH.md § Code Example 2 (lines 514-565).
ViewModel observation pattern (LoginScreen.kt:39-42):
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
// ...
}
Apply to AppShell:
- Take no params (it lives behind the auth gate). Inside:
val vm: ShellViewModel = koinViewModel(); val ui by vm.state.collectAsStateWithLifecycle(). - Acquire
navController = rememberNavController(); renderRootNavHost(navController)as the body. - Bottom chrome is an
Align(BottomCenter)overlay column:if (ui.searchOpen && activeTab.hasSearch) SearchPill(...); DockBar(active=activeTab, collapsed=ui.searchOpen, ...). FloatingSearchButtonalignedBottomEnd, visible only when!ui.searchOpen && activeTab.hasSearch.
Inset handling (avoid Pitfall F, RESEARCH.md lines 471-473): Modifier.windowInsetsPadding(WindowInsets.navigationBars) on the chrome column; screen bodies use WindowInsets.statusBars for top inset only. Do NOT use safeContentPadding() on AppShell — that's LoginScreen.kt:52's pattern, but only because LoginScreen has no chrome overlay. AppShell has chrome, so it must consume insets explicitly.
ui/screens/{planner,recipes,pantry,shopping}/{Tab}Screen.kt (new — analog: PostLoginPlaceholderScreen)
Analog: ui/screens/auth/PostLoginPlaceholderScreen.kt:32-62
Skeleton to mirror (PostLoginPlaceholderScreen.kt:38-61):
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_welcome_format, user.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
// ...
}
}
Adapt for tab screens:
- Replace
Surface(... color = MaterialTheme.colorScheme.surface)withBox(Modifier.fillMaxSize().background(RecipeTheme.colors.background)). UI-SPEC line 184: tab body background isRecipeColors.background, NOT a MaterialSurface. Also, do NOT importandroidx.compose.material3.*in new screen code (CLAUDE.md / UI-SPEC line 31 / RESEARCH.md anti-pattern at line 419). - Replace
MaterialTheme.typography.headlineSmallwithRecipeTheme.typography.titlefor the inline tab title (UI-SPEC line 64). - Replace hardcoded
padding(horizontal = 16.dp)withpadding(horizontal = RecipeTheme.spacing.lg)(UI-SPEC § Spacing). - The body region: inline title at top with
RecipeTheme.spacing.xltop inset, thenEmptyState(icon = ..., title = stringResource(Res.string.empty_<tab>_title), subtitle = stringResource(Res.string.empty_<tab>_subtitle))centered. - Each
*Screen(vm: *ViewModel)takes its VM as a parameter so the composable is testable / previewable in isolation; the call site inRootNavHost'scomposable<*Home>block does thekoinViewModel(viewModelStoreOwner = parentEntry)retrieval (RESEARCH.md § Pattern 2, lines 351-357).
ui/components/empty/EmptyState.kt (new — analog: LoginScreen column skeleton)
Analog: ui/screens/auth/LoginScreen.kt:48-92 for the centered Column pattern; full target shape locked by RESEARCH.md § Code Example 3 (lines 571-605) and UI-SPEC line 183.
Centered Column pattern from analog (LoginScreen.kt:48-56):
Column(
modifier = Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { /* ... */ }
Apply to EmptyState:
- Signature locked by D-13 / UI-SPEC line 183:
EmptyState(icon: ImageVector, title: String, subtitle: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null). - Replace
safeContentPadding()with explicit horizontalRecipeTheme.spacing.xl(UI-SPEC line 183 sets the body inset and screen-level safe-area inset is owned by the screen, not the empty-state). - Tint:
Icon(... tint = RecipeTheme.colors.contentMuted, modifier = Modifier.size(48.dp))— UI-SPEC line 183. - Spacing rhythm: icon →
Spacer(Modifier.height(RecipeTheme.spacing.sm))→ headline (RecipeTheme.typography.display, colorRecipeTheme.colors.content) →Spacer(... .lg)→ subline (RecipeTheme.typography.body, colorRecipeTheme.colors.contentMuted) → ifaction != null,Spacer(... .xl)thenaction(). - Wrap the Column in
Modifier.semantics(mergeDescendants = true) {}(UI-SPEC line 226; one-announce VoiceOver reading).
di/ShellModule.kt (new — analog: auth/AuthModule.kt)
Analog: auth/AuthModule.kt:9-25 and user/UserModule.kt:10-23.
Module + viewModel registration pattern (AuthModule.kt:9-25):
val authModule =
module {
single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
single<OidcClient> { OidcClient(get()) }
single<AuthSession> { AuthSession(oidcClient = get<OidcClient>(), store = get<SecureAuthStateStore>()) }
single<HttpClient> { AuthHttpClient.create(get()) }
viewModel<LoginViewModel>()
viewModel<PostLoginViewModel>()
}
Apply to shellModule:
viewModel<ShellViewModel>(),viewModel<PlannerViewModel>(),viewModel<RecipesViewModel>(),viewModel<RecipesSearchViewModel>(),viewModel<PantryViewModel>(),viewModel<PantrySearchViewModel>(),viewModel<ShoppingViewModel>().- A
single<GlassBackend> { resolveGlassBackend(get<Settings>()) }if the debug-toggle resolution is materialized at module level. Settings comes frommultiplatform-settings(RESEARCH.md A5 — already wired from Phase 2). - Same imports:
import org.koin.dsl.module,import org.koin.plugin.module.dsl.viewModel.
di/AppModule.kt (modified)
Analog: self.
Pattern to extend (AppModule.kt:8-11):
val appModule =
module {
includes(authModule, userModule)
}
Modification: add shellModule to the includes(...) list. One-line change. The comment on line 7 should be updated to reflect Phase 2.1 addition.
composeResources/values/strings.xml (modified)
Analog: self — current file has the auth_* keys.
Pattern to extend (full current file shown above — strings.xml:7-15). Add the shell_*, empty_*, search_* resource keys per UI-SPEC § Copywriting Contract (lines 121-158) and RESEARCH.md § Code Example 4 (lines 615-637). Preserve all existing auth_* keys; only append.
ui/components/dock/DockBar.kt (new — greenfield)
Analog: none in repo. Stylistic reference: LoginScreen.kt:62-80 (button structure with conditional content via if (state.isLoading)).
Key API contract (locked by UI-SPEC line 180 + CONTEXT D-01 through D-05):
- Signature:
DockBar(destinations: List<BottomBarDestination>, active: BottomBarDestination, collapsed: Boolean, onTabSelect: (BottomBarDestination) -> Unit, onCollapsedTap: () -> Unit, modifier: Modifier = Modifier). - Substrate:
GlassSurface(cornerRadius = 28.dp, ...)for expanded;GlassSurface(cornerRadius = 22.dp, ...)for collapsed (UI-SPEC line 253). - Built on Compose Unstyled
TabGroupprimitive (UI-SPEC line 180; RESEARCH.md line 137 —com.composables:composeunstyled:1.49.9). - Animation:
Modifier.animateContentSize()for expanded↔collapsed size +AnimatedContentfor icon/label visibility crossfade. 250msFastOutSlowInEasingper UI-SPEC line 198. Single coordinated motion (D-05). - Each cell:
Modifier.semantics { role = Role.Tab; selected = isActive }(UI-SPEC line 220). - Touch target ≥ 44dp on iOS / 48dp on Android (UI-SPEC line 52, 224).
ui/components/dock/FloatingSearchButton.kt (new — greenfield)
Analog: none. UI-SPEC line 181.
- Signature:
FloatingSearchButton(onClick: () -> Unit, modifier: Modifier = Modifier). - Built on Compose Unstyled
Button, wrapping aGlassSurface(cornerRadius = 22.dp)(44dp full-circle). - Icon:
Icons.Outlined.Search, tintedRecipeTheme.colors.content. contentDescription = stringResource(Res.string.search_open_a11y).
ui/components/search/SearchPill.kt (new — greenfield)
Analog: stylistic only — nothing equivalent in repo. UI-SPEC line 182.
- Signature:
SearchPill(query: String, onQueryChange: (String) -> Unit, onClear: () -> Unit, onClose: () -> Unit, placeholder: String, modifier: Modifier = Modifier). - Built on Compose Unstyled
TextFieldrenderless primitive — apply local styling, do NOT roll a MaterialOutlinedTextField. - 44dp height, 22dp corner radius,
surfaceGlasssubstrate (UI-SPEC line 253). - Leading search icon, trailing clear button visible only when
query.isNotEmpty(). imePadding()so the pill rides above the soft keyboard (UI-SPEC line 271; Pitfall F).
ui/components/glass/GlassSurface.kt + backends (new — all greenfield)
Analog: none. RESEARCH.md § Pattern 3 (lines 367-388) is the API lock.
@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,
)
- Backend selected via
LocalGlassBackend.current(CompositionLocal set once atRecipeTheme/AppShellstartup). - Compile-time per target via
expect/actualof anexpect val defaultGlassBackend: GlassBackendincommonMainwithactuals iniosMain(Liquid) andandroidMain(Liquid). If targets emerge where Liquid does not compile, theactualreturnsHaze. - Debug runtime override:
multiplatform-settingskey"debug.glass_backend"checked atRecipeThemeinit, in DEBUG builds only (gate via anexpect val isDebugBuild: Boolean). Production binaries compile out the override path. - Liquid path uses
rememberLiquidState()+Modifier.liquefiable(state)on the screen-body backdrop (set atAppShelllevel — Pitfall C, RESEARCH.md lines 454-458) andModifier.liquid(state)on the chrome (DockBar,SearchPill,FloatingSearchButtoninteriors).
navigation/Routes.kt, BottomBarDestination.kt, RootNavHost.kt (new — all greenfield)
Analog: none. RESEARCH.md § Pattern 1 (lines 304-339) and § Code Example 1 (lines 487-510) lock the shape verbatim.
Key contracts:
@Serializable data object PlannerGraph; @Serializable data object PlannerHome; ...— type-safe routing.enum class BottomBarDestination(val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector, val hasSearch: Boolean, val searchPlaceholder: StringResource?). ThehasSearchflag drives D-06 (search visibility per tab).NavHostController.navigateToTab(graphRoute: Any)extension appliespopUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true. This is the unit under test inNavigationTest.kt.- Per-tab VM scoping: in each
composable<*Home>block,val parent = remember(entry) { navController.getBackStackEntry(*Graph) }; val vm: *ViewModel = koinViewModel(viewModelStoreOwner = parent)(RESEARCH.md § Pattern 2). Set this pattern now even with a single screen per graph — Phase 5 inherits cleanly.
Test files (new)
Analog: commonTest/.../ui/screens/auth/LoginViewModelTest.kt:21-77 for VM tests; commonTest/.../auth/AuthSessionTest.kt:11-29 for state-flow gate tests.
Pattern from LoginViewModelTest.kt (lines 22-32):
class LoginViewModelTest {
@Test
fun cancelledAuthFailureMapsToCancelledStringResource() =
runTest {
val session = newSession(loginResult = OidcResult.Cancelled)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
}
}
Apply to RecipesSearchViewModelTest / PantrySearchViewModelTest:
runTest { ... }block; no fakes needed (VMs are pure — no I/O).- Cover: open() →
isOpen=true; onQueryChange("foo") →query="foo"; close() →isOpen=false, query=""(D-08); clear() →query="", isOpen=true(UI-SPEC line 206 + CONTEXT D-08).
Apply to AppShellGateTest — mirror AuthSessionTest.kt:13-23 shape (state-machine assertion via runTest). Drives App() indirectly by stubbing AuthSession + UserRepository via Koin test container, asserts Authenticated + currentUser != null resolves to AppShell rather than the placeholder. Plan to inject test doubles via Koin startKoin { modules(...) } per Koin.kt:7-11 shape.
Apply to NavigationTest — assert the navigateToTab(...) extension's NavOptionsBuilder lambda flips the four flags. If TestNavHostController is unavailable in CMP commonTest, assert by capturing a fake builder. Mark this as an investigation point in Wave 0.
Apply to GlassBackendTest / GlassBackendOverrideTest — pure-function tests over the resolveGlassBackend(settings: Settings, isDebug: Boolean, default: GlassBackend) function. Use MapSettings (multiplatform-settings test impl) per RESEARCH.md line 731.
Shared Patterns
Externalized strings (UI-01, CLAUDE.md #9)
Source: composeResources/values/strings.xml + recipe.composeapp.generated.resources.Res.
Apply to: every new screen, every new component that displays user-facing text. Zero hardcoded literals.
Reference call site (LoginScreen.kt:28-31, 58, 78):
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
// ...
Text(text = stringResource(Res.string.auth_app_name), ...)
ViewModel-side resource handles — when a VM needs to surface a string to the screen but stay locale-agnostic, return a StringResource (not a String). See LoginViewModel.kt:13, 24, 57-63:
import org.jetbrains.compose.resources.StringResource
// ...
data class LoginScreenState(val isLoading: Boolean = false, val errorKey: StringResource? = null)
This phase: search VM state holds the raw query: String (it's user input, not a localized message). The placeholder for the search pill is resolved via the per-tab searchPlaceholder: StringResource on BottomBarDestination.
ViewModel + StateFlow + method-per-action (CLAUDE.md convention)
Source: LoginViewModel.kt:37-55, PostLoginViewModel.kt:15-23.
Apply to: ShellViewModel, PlannerViewModel, RecipesViewModel, RecipesSearchViewModel, PantryViewModel, PantrySearchViewModel, ShoppingViewModel.
Universal shape:
private val _state = MutableStateFlow(<TabState>())val state: StateFlow<TabState> = _state.asStateFlow()- Each action is a method on the VM that calls
_state.update { ... }or_state.value = .... - No
LiveData, nomutableStateOffor primary state —StateFlowonly.
Screen → VM observation
Source: App.kt:33-34, LoginScreen.kt:40.
Pattern:
import androidx.lifecycle.compose.collectAsStateWithLifecycle
// ...
val state by viewModel.state.collectAsStateWithLifecycle()
Apply to: every new screen and to AppShell. Use collectAsStateWithLifecycle not collectAsState so iOS/Android lifecycle-aware suspension works.
Koin VM injection at composition
Source: App.kt:46, 55, AuthModule.kt:23-24.
Pattern:
- Module:
viewModel<*ViewModel>(). - Call site:
val vm: *ViewModel = koinViewModel<*ViewModel>()for non-tab-scoped, ORval parent = remember(entry) { navController.getBackStackEntry(*Graph) }; val vm: *ViewModel = koinViewModel(viewModelStoreOwner = parent)for tab-graph-scoped (RESEARCH.md § Pattern 2 — set the scoping pattern from day one).
iOS-safe inset handling
Apply to: AppShell (chrome insets), every screen body (top inset).
- Chrome bottom:
Modifier.windowInsetsPadding(WindowInsets.navigationBars)(or.union(WindowInsets.ime)for the search pill). - Body top: respect
WindowInsets.statusBarsvia padding. - Do NOT layer
safeContentPadding()on both AppShell and screens — Pitfall F.
Material 3 boundary
Source: UI-SPEC line 31; CLAUDE.md project decision; RESEARCH.md anti-pattern at line 419.
Apply to: every new file outside ui/screens/auth/. No androidx.compose.material3.* imports in new code. Tab screens replace Surface(... color = MaterialTheme.colorScheme.surface) with Box(Modifier.background(RecipeTheme.colors.background)). Replace MaterialTheme.typography.* with RecipeTheme.typography.*. Use Compose Unstyled primitives where a renderless analog exists.
The legacy auth screens (LoginScreen.kt, PostLoginPlaceholderScreen.kt, SplashScreen.kt) keep their Material 3 imports — explicit user discretion in CONTEXT line 52, default "leave auth screens as-is".
Glass on chrome only
Source: CLAUDE.md non-negotiable #10; PITFALLS Pitfall 5/12.
Apply to: GlassSurface is consumed by DockBar, FloatingSearchButton, SearchPill exclusively. Tab body / EmptyState / future list rows render flat. Lint discipline per Pitfall E — any direct Liquid/Haze API import outside ui/components/glass/ is a bug.
No Analog Found (greenfield, lean on RESEARCH.md / UI-SPEC)
| File | Role | Why no analog | Locked by |
|---|---|---|---|
navigation/Routes.kt + RootNavHost.kt + BottomBarDestination.kt |
nav graph | First nav graph in repo (Phase 2 used a when (authState) switch in App.kt) |
RESEARCH.md § Pattern 1, Code Example 1 |
ui/theme/RecipeColors.kt (full token set) |
semantic color scaffold | Current RecipeTheme.kt only has a 2-color seed |
UI-SPEC § Color (lines 84-92) |
ui/theme/RecipeTypography.kt |
typography scale | None exists | UI-SPEC § Typography (lines 60-72) |
ui/theme/RecipeSpacing.kt |
spacing tokens | None exists | UI-SPEC § Spacing (lines 36-54) |
ui/theme/RecipeShapes.kt |
shape tokens | None exists | UI-SPEC § Glass (line 253) |
ui/theme/RecipeGlass.kt |
glass token defaults | None exists | UI-SPEC § Glass (lines 248-256) |
ui/components/glass/GlassSurface.kt + 3 backends |
layered glass primitive | First Liquid/Haze use in repo | RESEARCH.md § Pattern 3, Liquid README |
ui/components/dock/DockBar.kt |
floating tab pill with collapse animation | First Compose Unstyled TabGroup consumer; first animated chrome |
UI-SPEC line 180; RESEARCH.md § Code Example 2 |
ui/components/dock/FloatingSearchButton.kt |
floating circular icon button | First Compose Unstyled Button consumer |
UI-SPEC line 181 |
ui/components/search/SearchPill.kt |
inline bottom search input | First Compose Unstyled TextField consumer; first IME-aware chrome |
UI-SPEC line 182; RESEARCH.md § Pattern 4 |
ui/components/empty/EmptyState.kt |
reusable empty-state | First component in ui/components/ |
UI-SPEC line 183; RESEARCH.md § Code Example 3 |
For these files, the planner should:
- Reference the locked API in UI-SPEC (signatures, dimensions, tokens).
- Reference the implementation patterns in RESEARCH.md (code examples + library APIs).
- Apply the shared patterns above (strings externalized, RecipeTheme tokens, no Material 3, glass-only-on-chrome) verbatim — these are not greenfield even when the file is.
Metadata
Analog search scope: composeApp/src/{commonMain,commonTest,iosMain,androidMain}/kotlin/dev/ulfrx/recipe/** — full client tree.
Files scanned: ~45 source files (entire current composeApp Kotlin tree post-Phase-2).
Strongest analogs identified: LoginViewModel.kt, PostLoginPlaceholderScreen.kt, AuthModule.kt, RecipeTheme.kt (current), LoginViewModelTest.kt, App.kt.
Pattern extraction date: 2026-05-08