39 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02.1 | 06 | execute | 3 |
|
|
true |
|
|
|
Replace the @Ignore'd Wave-0 stubs in RecipesSearchViewModelTest.kt (V-05 + V-06) and PantrySearchViewModelTest.kt (V-07) with real assertions covering open() → onQueryChange("foo") → close() → SearchState(isOpen=false, query="") (D-08) and clear() → SearchState(isOpen=true, query="") (D-07).
Both Search VMs are pure-state — no I/O this phase. The SearchSource type is declared as a placeholder interface in RecipesSearchViewModel.kt's package; Phase 5 implements it. Why declare the type now? So plan 02.1-08's ShellModule registers VMs with viewModel { RecipesSearchViewModel(searchSource = null) } cleanly.
Purpose: UI-10 hard-coded — search affordance functional before catalog data exists; open/close + query echo + clear/close work; no-results state is deliberate (renders nothing in the search-surface body — D-07). Output: 3 new commonMain files (2 VMs + SearchPill); 2 commonTest files un-ignored with real assertions covering V-05 / V-06 / V-07.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt @composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt @composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt After Wave 2 (plans 02.1-03, 02.1-04) lands:From plan 02.1-03 (ui/components/glass/):
@Composable fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
border: BorderStroke? = ...,
content: @Composable BoxScope.() -> Unit,
)
LoginViewModel pattern (analog from LoginViewModel.kt:37-55) — mirror this shape:
class XxxViewModel : ViewModel() {
private val _state = MutableStateFlow(XxxState())
val state: StateFlow<XxxState> = _state.asStateFlow()
fun action() { _state.update { ... } }
}
Compose Unstyled TextField (renderless primitive, com.composables:composeunstyled:1.49.9) — used by SearchPill per UI-SPEC line 182. The expected API is a TextField composable with slot-based styling. If the artifact's exact shape differs, the fallback is androidx.compose.foundation.text.BasicTextField from compose-foundation — NOT androidx.compose.material3.TextField (Material 3 forbidden in shell code). BasicTextField is a renderless equivalent and provides the same a11y / IME plumbing.
Resource keys to be used (added by plan 02.1-04 before this plan runs):
Res.string.search_clear_a11y("Wyczyść")Res.string.search_close_a11y("Zamknij wyszukiwanie")Res.string.search_placeholder_recipes("Szukaj przepisów…") — from plan 02.1-04, already presentRes.string.search_placeholder_pantry("Szukaj w spiżarni…") — from plan 02.1-04, already present
The placeholder text is passed in as a String parameter (not a StringResource) so the SearchPill stays decoupled from per-tab resource keys. AppShell (plan 02.1-05) resolves the placeholder via stringResource(activeTab.searchPlaceholder) and hands it to SearchPill.
```kotlin
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 = "") }
}
}
```
Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt`:
```kotlin
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 [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 = "") }
}
}
```
Note: `SearchState` and `SearchSource` are declared once in `ui.screens.recipes` and
re-imported by `ui.screens.pantry`. This avoids drift between the two VMs and
matches the RESEARCH § Pattern 4 contract that both have the same shape.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q
- `grep -c 'data class SearchState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
- `grep -c 'interface SearchSource' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
- `grep -c 'class RecipesSearchViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
- `grep -c 'searchSource: SearchSource? = null' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
- All 4 actions on Recipes VM: `grep -cE 'fun (open|close|onQueryChange|clear)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 4
- close() resets isOpen and query: `awk '/fun close/,/^ }$/' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt | grep -c 'isOpen = false, query = ""'` returns 1
- clear() does not touch isOpen: `awk '/fun clear/,/^ }$/' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt | grep -c 'isOpen'` returns 0
- `grep -c 'class PantrySearchViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 1
- `grep -c 'searchSource: SearchSource? = null' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 1
- All 4 actions on Pantry VM: `grep -cE 'fun (open|close|onQueryChange|clear)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 4
- PantrySearchViewModel imports SearchState and SearchSource from `ui.screens.recipes`: `grep -c 'import dev.ulfrx.recipe.ui.screens.recipes.Search' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns at least 2
- Material 3 boundary: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 0
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
Two SearchViewModels with identical 4-action API and SearchState shape; SearchState + SearchSource declared once in recipes package and reused by pantry. Phase 5/8 extension hook (nullable searchSource) is in place. Build is green.
Task 2: Create SearchPill.kt — inline bottom search pill on GlassSurface substrate
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt — public API from plan 02.1-03
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — token accessors from plan 02.1-02
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Component Inventory line 182 — SearchPill shape
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Glass / Liquid contract lines 248-256 — corner radius 22dp, height 44dp
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Layout & Safe Area line 271 — imePadding for keyboard avoidance
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Accessibility line 223 — clear button only when query non-empty; contentDescription = search_clear_a11y
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § SearchPill (lines 341-348)
- composeApp/src/commonMain/composeResources/values/strings.xml — verify search_clear_a11y / search_close_a11y already exist from plan 02.1-04; do not edit this file in plan 02.1-06
Step 1 — verify resource-key prerequisites from plan 02.1-04:
```bash
grep -c 'search_clear_a11y\|search_close_a11y' composeApp/src/commonMain/composeResources/values/strings.xml
```
The count MUST be 2. If it is not, stop and repair/re-run plan 02.1-04; do not add
keys here because plan 02.1-06 has no strings.xml ownership.
Step 2 — create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt`:
```kotlin
package dev.ulfrx.recipe.ui.components.search
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardCapitalization
import androidx.compose.foundation.text.input.ImeAction
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
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_clear_a11y
import recipe.composeapp.generated.resources.search_close_a11y
/**
* 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 [Icons.Outlined.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).
* - Trailing clear icon — visible ONLY when [query] is non-empty (UI-SPEC line 223).
* - Trailing close icon — always visible; tap dismisses the search per D-08.
*
* 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.
*
* Accessibility: clear button has [search_clear_a11y]; close button has
* [search_close_a11y]. 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,
onClear: () -> Unit,
onClose: () -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
) {
val clearLabel = stringResource(Res.string.search_clear_a11y)
val closeLabel = stringResource(Res.string.search_close_a11y)
GlassSurface(
modifier = modifier
.fillMaxWidth()
.height(44.dp)
.padding(horizontal = RecipeTheme.spacing.lg),
cornerRadius = 22.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = RecipeTheme.spacing.lg),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
// Leading search icon.
Image(
painter = rememberVectorPainter(image = Icons.Outlined.Search),
contentDescription = null,
colorFilter = ColorFilter.tint(RecipeTheme.colors.contentMuted),
modifier = Modifier.size(20.dp),
)
// Query input — fills available width.
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = query,
onValueChange = onQueryChange,
textStyle = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
cursorBrush = SolidColor(RecipeTheme.colors.accent),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerField ->
if (query.isEmpty()) {
BasicTextWithStyle(
text = placeholder,
color = RecipeTheme.colors.contentMuted,
style = RecipeTheme.typography.body,
)
}
innerField()
},
)
}
// Trailing clear icon — only when query is non-empty.
if (query.isNotEmpty()) {
val clearInteraction = remember { MutableInteractionSource() }
Image(
painter = rememberVectorPainter(image = Icons.Outlined.Close),
contentDescription = null,
colorFilter = ColorFilter.tint(RecipeTheme.colors.contentMuted),
modifier = Modifier
.size(20.dp)
.clickable(
interactionSource = clearInteraction,
indication = null,
onClick = onClear,
)
.semantics { contentDescription = clearLabel },
)
}
// Trailing close icon — always visible inside the pill.
val closeInteraction = remember { MutableInteractionSource() }
Image(
painter = rememberVectorPainter(image = Icons.Outlined.Close),
contentDescription = null,
colorFilter = ColorFilter.tint(RecipeTheme.colors.content),
modifier = Modifier
.size(20.dp)
.clickable(
interactionSource = closeInteraction,
indication = null,
onClick = onClose,
)
.semantics { contentDescription = closeLabel },
)
}
}
}
/**
* Internal helper — placeholder text rendered when the BasicTextField is empty.
* Plain text in [RecipeTheme.typography.body] tinted [RecipeTheme.colors.contentMuted].
*/
@Composable
private fun BasicTextWithStyle(
text: String,
color: androidx.compose.ui.graphics.Color,
style: androidx.compose.ui.text.TextStyle,
) {
androidx.compose.foundation.text.BasicText(
text = text,
style = style.copy(color = color),
)
}
```
Implementation note: the close button visually duplicates the trailing clear icon
(both are X glyphs). UI-SPEC § Accessibility line 223 distinguishes them by
contentDescription only. If a future revision wants distinct glyphs (e.g. arrow-down
for close), that's a Phase 10 polish concern — this phase ships functional parity
with the spec. The clear button is OPTIONAL (visible only when query non-empty); the
close button is ALWAYS visible inside the pill. The user can dismiss the search by
tapping either the close button OR the dock's collapsed toggle (which is OUTSIDE the
pill, owned by DockBar from plan 02.1-05).
Implementation note 2: Compose Unstyled's `TextField` API was the originally
specified primitive. If the artifact's API at 1.49.9 does not expose a renderless
`TextField` that delegates to `BasicTextField` cleanly, use `BasicTextField` directly
as above — `compose-foundation` provides it and that's already on the classpath.
`BasicTextField` is itself renderless (no Material 3 chrome). Document the chosen
primitive in the SUMMARY.
Material 3 boundary check: NO `androidx.compose.material3.*` imports.
`androidx.compose.material.icons.outlined.*` is fine — it's the icon set, not
Material 3 components.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q
- `grep -c 'fun SearchPill' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 1
- SearchPill signature: takes query, onQueryChange, onClear, onClose, placeholder — `grep -c 'query: String\|onQueryChange: (String)\|onClear: () -> Unit\|onClose: () -> Unit\|placeholder: String' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 5
- GlassSurface substrate: `grep -c 'GlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 1
- 22dp corner radius: `grep -c 'cornerRadius = 22.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 1
- 44dp height: `grep -c '44.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 1
- Conditional clear: `grep -c 'query.isNotEmpty' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 1
- A11y descriptions: `grep -c 'search_clear_a11y\|search_close_a11y' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 2
- Leading Search icon: `grep -c 'Icons.Outlined.Search' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 1
- Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 0
- Liquid / Haze imports forbidden in search package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/ | wc -l` returns 0
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
SearchPill renders an inline 44dp-height GlassSurface pill with leading search icon, BasicTextField for query input, conditional clear button, and always-visible close button. A11y descriptions resolve via stringResource. Material 3 zero imports.
Task 3: Replace @Ignore stubs in RecipesSearchViewModelTest + PantrySearchViewModelTest with real assertions covering V-05 / V-06 / V-07
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt,
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt — current Wave-0 stub
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt — current Wave-0 stub
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt — just created
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt — just created
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/LoginViewModelTest.kt — kotlin.test pattern shape
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md § Per-Task Verification Map V-05 / V-06 / V-07 (lines 50-52)
Replace the Wave-0 `@Ignore`'d body of `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` with:
```kotlin
package dev.ulfrx.recipe.ui.screens.recipes
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
/**
* 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)
}
}
```
Replace the Wave-0 `@Ignore`'d body of `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` with:
```kotlin
package dev.ulfrx.recipe.ui.screens.pantry
import dev.ulfrx.recipe.ui.screens.recipes.SearchState
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
/**
* 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)
}
}
```
Both files MUST drop the `@Ignore` import + annotation. Both use `kotlin.test` only.
./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModelTest" --tests "dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModelTest" -q
- `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns 0
- `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` returns 0
- V-05 covered: `grep -c 'openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns 1
- V-06 covered: `grep -c 'clear_resetsQueryButKeepsIsOpenTrue' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns 1
- V-07 covered: `grep -c 'openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` returns 1
- Recipes test has at least 5 @Test functions: `grep -c '@Test' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns at least 5
- Pantry test has at least 3 @Test functions: `grep -c '@Test' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` returns at least 3
- `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModelTest" --tests "dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModelTest" -q` exits 0
RecipesSearchViewModelTest contains 5 passing assertions covering V-05 + V-06 + edge cases; PantrySearchViewModelTest contains 3 passing assertions covering V-07; @Ignore is gone from both files. UI-10 has its core unit-test coverage.
- iOS K/N compile green: `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
- Search VM tests pass: `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModelTest" --tests "dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModelTest" -q` exits 0
- iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0
- Material 3 boundary preserved across all 3 new common files: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 0
- Liquid / Haze imports zero outside glass package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt | wc -l` returns 0
<success_criteria>
- RecipesSearchViewModel.kt declares SearchState (data class) + SearchSource (placeholder interface) + RecipesSearchViewModel class with 4 actions (open / close / onQueryChange / clear).
- PantrySearchViewModel.kt declares PantrySearchViewModel class with the same 4-action API; imports SearchState + SearchSource from
ui.screens.recipespackage. - Both VMs accept nullable searchSource: SearchSource? = null constructor parameter (Phase 5 / 8 extension hook per RESEARCH § Pattern 4 line 410).
- close() clears query (D-08) on both VMs; clear() preserves isOpen (D-07) on both VMs.
- SearchPill renders 44dp-height pill on GlassSurface(cornerRadius = 22.dp) with leading search icon, BasicTextField input, conditional clear button (visible only when query non-empty per UI-SPEC line 223), and always-visible close button. A11y descriptions resolve from
search_clear_a11y/search_close_a11y. - V-05 anchor: RecipesSearchViewModelTest passes 5 assertions.
- V-06 anchor: covered by RecipesSearchViewModelTest (
clear_resetsQueryButKeepsIsOpenTrue). - V-07 anchor: PantrySearchViewModelTest passes 3 assertions.
- Material 3 boundary preserved: zero
androidx.compose.material3imports in any new file. </success_criteria>