Files

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:59MaterialTheme.colorScheme.surface, MaterialTheme.typography.displaySmall) keep resolving (Open Question 3, recommended resolution).
  • Inside the MaterialTheme { ... }, wrap a CompositionLocalProvider(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 a companion object-style object 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)); expose state: 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.launch needed — 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(); render RootNavHost(navController) as the body.
  • Bottom chrome is an Align(BottomCenter) overlay column: if (ui.searchOpen && activeTab.hasSearch) SearchPill(...); DockBar(active=activeTab, collapsed=ui.searchOpen, ...).
  • FloatingSearchButton aligned BottomEnd, 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) with Box(Modifier.fillMaxSize().background(RecipeTheme.colors.background)). UI-SPEC line 184: tab body background is RecipeColors.background, NOT a Material Surface. Also, do NOT import androidx.compose.material3.* in new screen code (CLAUDE.md / UI-SPEC line 31 / RESEARCH.md anti-pattern at line 419).
  • Replace MaterialTheme.typography.headlineSmall with RecipeTheme.typography.title for the inline tab title (UI-SPEC line 64).
  • Replace hardcoded padding(horizontal = 16.dp) with padding(horizontal = RecipeTheme.spacing.lg) (UI-SPEC § Spacing).
  • The body region: inline title at top with RecipeTheme.spacing.xl top inset, then EmptyState(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 in RootNavHost's composable<*Home> block does the koinViewModel(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 horizontal RecipeTheme.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, color RecipeTheme.colors.content) → Spacer(... .lg) → subline (RecipeTheme.typography.body, color RecipeTheme.colors.contentMuted) → if action != null, Spacer(... .xl) then action().
  • 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 from multiplatform-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 TabGroup primitive (UI-SPEC line 180; RESEARCH.md line 137 — com.composables:composeunstyled:1.49.9).
  • Animation: Modifier.animateContentSize() for expanded↔collapsed size + AnimatedContent for icon/label visibility crossfade. 250ms FastOutSlowInEasing per 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 a GlassSurface(cornerRadius = 22.dp) (44dp full-circle).
  • Icon: Icons.Outlined.Search, tinted RecipeTheme.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 TextField renderless primitive — apply local styling, do NOT roll a Material OutlinedTextField.
  • 44dp height, 22dp corner radius, surfaceGlass substrate (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 at RecipeTheme/AppShell startup).
  • Compile-time per target via expect/actual of an expect val defaultGlassBackend: GlassBackend in commonMain with actuals in iosMain (Liquid) and androidMain (Liquid). If targets emerge where Liquid does not compile, the actual returns Haze.
  • Debug runtime override: multiplatform-settings key "debug.glass_backend" checked at RecipeTheme init, in DEBUG builds only (gate via an expect val isDebugBuild: Boolean). Production binaries compile out the override path.
  • Liquid path uses rememberLiquidState() + Modifier.liquefiable(state) on the screen-body backdrop (set at AppShell level — Pitfall C, RESEARCH.md lines 454-458) and Modifier.liquid(state) on the chrome (DockBar, SearchPill, FloatingSearchButton interiors).

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?). The hasSearch flag drives D-06 (search visibility per tab).
  • NavHostController.navigateToTab(graphRoute: Any) extension applies popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true. This is the unit under test in NavigationTest.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, no mutableStateOf for primary state — StateFlow only.

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, OR val 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.statusBars via 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:

  1. Reference the locked API in UI-SPEC (signatures, dimensions, tokens).
  2. Reference the implementation patterns in RESEARCH.md (code examples + library APIs).
  3. 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