Implement main app navigation
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user