678 lines
39 KiB
Markdown
678 lines
39 KiB
Markdown
---
|
|
phase: 02.1
|
|
plan: 06
|
|
type: execute
|
|
wave: 3
|
|
depends_on: ["02.1-03", "02.1-04"]
|
|
files_modified:
|
|
- 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
|
|
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt
|
|
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt
|
|
autonomous: true
|
|
requirements: [UI-10]
|
|
tags: [kotlin, compose-multiplatform, search, viewmodel, compose-unstyled, glass, accessibility, ime, phase-5-extension-hook]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "RecipesSearchViewModel and PantrySearchViewModel each expose state: StateFlow<SearchState> with open() / close() / onQueryChange(q) / clear() methods (RESEARCH § Pattern 4)"
|
|
- "close() clears the query and sets isOpen=false: SearchState(isOpen=false, query=\"\") — D-08"
|
|
- "clear() resets only query, keeps isOpen=true: state.copy(query=\"\") — D-07"
|
|
- "Both VMs accept a nullable searchSource: SearchSource? = null constructor parameter — Phase 5 extension point per RESEARCH § Pattern 4 line 410"
|
|
- "SearchPill is a 44dp-height pill consuming GlassSurface(cornerRadius=22.dp) per UI-SPEC line 182 + 253"
|
|
- "SearchPill uses Modifier.imePadding() so the pill rides above the soft keyboard (UI-SPEC line 271 / Pitfall F)"
|
|
- "SearchPill leading icon = Icons.Outlined.Search; trailing clear button visible ONLY when query.isNotEmpty(); a11y descriptions: search_clear_a11y for clear, search_close_a11y for close"
|
|
- "V-05 + V-06 (RecipesSearchViewModelTest) and V-07 (PantrySearchViewModelTest) replace @Ignore stubs with real assertions covering open / onQueryChange / close / clear semantics"
|
|
artifacts:
|
|
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt"
|
|
provides: "RecipesSearchViewModel + SearchState + SearchSource interface placeholder"
|
|
contains: "class RecipesSearchViewModel"
|
|
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt"
|
|
provides: "PantrySearchViewModel"
|
|
contains: "class PantrySearchViewModel"
|
|
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt"
|
|
provides: "SearchPill composable — inline bottom search input"
|
|
contains: "fun SearchPill"
|
|
key_links:
|
|
- from: "ui/components/search/SearchPill.kt"
|
|
to: "ui/components/glass/GlassSurface.kt"
|
|
via: "GlassSurface(cornerRadius = 22.dp) substrate"
|
|
pattern: "GlassSurface"
|
|
- from: "commonTest/.../RecipesSearchViewModelTest.kt"
|
|
to: "ui/screens/recipes/RecipesSearchViewModel.kt"
|
|
via: "instantiates VM and asserts SearchState transitions"
|
|
pattern: "RecipesSearchViewModel"
|
|
- from: "commonTest/.../PantrySearchViewModelTest.kt"
|
|
to: "ui/screens/pantry/PantrySearchViewModel.kt"
|
|
via: "instantiates VM and asserts SearchState transitions"
|
|
pattern: "PantrySearchViewModel"
|
|
---
|
|
|
|
<objective>
|
|
Build the search foundation — two per-tab Search ViewModels (RecipesSearchViewModel, PantrySearchViewModel) following RESEARCH § Pattern 4 with the locked SearchState shape and 4 method-per-action signatures, plus the SearchPill composable that renders the inline bottom search input on a 44dp-height GlassSurface pill (UI-SPEC line 182). The two VMs each accept a nullable `searchSource: SearchSource? = null` constructor parameter — Phase 5's extension hook per RESEARCH § Pattern 4 line 410.
|
|
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
After Wave 2 (plans 02.1-03, 02.1-04) lands:
|
|
|
|
From plan 02.1-03 (`ui/components/glass/`):
|
|
```kotlin
|
|
@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:
|
|
```kotlin
|
|
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 present
|
|
- `Res.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.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create RecipesSearchViewModel.kt + PantrySearchViewModel.kt + SearchSource placeholder interface</name>
|
|
<files>
|
|
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt,
|
|
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt
|
|
</files>
|
|
<read_first>
|
|
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt — analog VM shape (LoginViewModel.kt:37-55)
|
|
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pattern 4 (lines 390-410) — verbatim SearchState + VM shape
|
|
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md line 410 — Phase 5 extension hook: nullable searchSource parameter
|
|
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-07 + D-08 (lines 33-35) — close() clears query; clear() preserves isOpen
|
|
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § ShellViewModel (lines 151-179) — SearchState semantics also described here
|
|
</read_first>
|
|
<action>
|
|
Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt`:
|
|
|
|
```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.
|
|
</action>
|
|
<verify>
|
|
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create SearchPill.kt — inline bottom search pill on GlassSurface substrate</name>
|
|
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt</files>
|
|
<read_first>
|
|
- 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
|
|
</read_first>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Replace @Ignore stubs in RecipesSearchViewModelTest + PantrySearchViewModelTest with real assertions covering V-05 / V-06 / V-07</name>
|
|
<files>
|
|
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt,
|
|
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt
|
|
</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModelTest" --tests "dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModelTest" -q</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- 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
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
1. RecipesSearchViewModel.kt declares SearchState (data class) + SearchSource (placeholder interface) + RecipesSearchViewModel class with 4 actions (open / close / onQueryChange / clear).
|
|
2. PantrySearchViewModel.kt declares PantrySearchViewModel class with the same 4-action API; imports SearchState + SearchSource from `ui.screens.recipes` package.
|
|
3. Both VMs accept nullable searchSource: SearchSource? = null constructor parameter (Phase 5 / 8 extension hook per RESEARCH § Pattern 4 line 410).
|
|
4. close() clears query (D-08) on both VMs; clear() preserves isOpen (D-07) on both VMs.
|
|
5. 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`.
|
|
6. V-05 anchor: RecipesSearchViewModelTest passes 5 assertions.
|
|
7. V-06 anchor: covered by RecipesSearchViewModelTest (`clear_resetsQueryButKeepsIsOpenTrue`).
|
|
8. V-07 anchor: PantrySearchViewModelTest passes 3 assertions.
|
|
9. Material 3 boundary preserved: zero `androidx.compose.material3` imports in any new file.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-06-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record:
|
|
- Whether Compose Unstyled's `TextField` was used or BasicTextField was the fallback in SearchPill, and why.
|
|
- Whether `search_clear_a11y` / `search_close_a11y` were present from plan 02.1-04 before SearchPill compilation.
|
|
- Whether the SearchSource placeholder interface declaration is in `recipes/` package as planned, or moved (and why).
|
|
- Plan 02.1-05 (AppShell) dependency handoff: confirm AppShell consumed this plan's SearchPill and per-tab Search ViewModels directly, with no local stubs.
|
|
</output>
|