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