Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 6684b7179d
commit e0af5f4053
92 changed files with 8140 additions and 208 deletions

View File

@@ -1,52 +1,54 @@
package dev.ulfrx.recipe
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.painterResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.compose_multiplatform
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.ui.screens.auth.LoginScreen
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Auth gate. Observes [AuthSession.state] and renders Splash / Login / PostLogin per
* `02-UI-SPEC.md` § Auth Gate Routing Contract. State changes drive recomposition;
* no manual navigation. Phase 3 replaces the `Authenticated` branch with `HouseholdGate`.
*/
@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
RecipeTheme {
val authSession = koinInject<AuthSession>()
val authState by authSession.state.collectAsStateWithLifecycle()
// Kick off the persisted-session restore once. AuthSession.initialize()
// refreshes the stored AuthState (or transitions to Unauthenticated on
// empty store / refresh failure) and the gate below recomposes accordingly.
LaunchedEffect(authSession) {
authSession.initialize()
}
when (val current = authState) {
AuthState.Loading -> {
SplashScreen()
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
}
AuthState.Unauthenticated -> {
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
}
is AuthState.Authenticated -> {
PostLoginPlaceholderScreen(
user = current.user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
}

View File

@@ -0,0 +1,60 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.Constants
import io.ktor.client.HttpClient
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
object AuthHttpClient {
fun create(authSession: AuthSession): HttpClient =
HttpClient {
install(ContentNegotiation) {
json(authJson)
}
install(Auth) {
bearer {
loadTokens {
authSession.currentBearerTokens()
}
refreshTokens {
authSession.refreshBearerTokens()
}
sendWithoutRequest { request ->
request.url.host == Url(Constants.API_BASE_URL).host
}
}
}
install(Logging) {
level = LogLevel.HEADERS
sanitizeHeader { header -> header.equals(HttpHeaders.Authorization, ignoreCase = true) }
logger =
object : Logger {
override fun log(message: String) {
co.touchlab.kermit.Logger
.withTag("auth-http")
.i(redact(message))
}
}
}
}
private fun redact(message: String): String =
message
.replace(Regex("Bearer\\s+[^\\s,;]+"), "Bearer <redacted>")
.replace(Regex("(?i)(Authorization:\\s*)[^\\n\\r]+")) { match ->
match.groupValues[1] + "<redacted>"
}
private val authJson =
Json {
ignoreUnknownKeys = true
}
}

View File

@@ -0,0 +1,27 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import io.ktor.client.HttpClient
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val authModule =
module {
single<SecureAuthStateStore> { SecureAuthStateStore() }
single<OidcClient> { OidcClient() }
single<MeClient> { MeClient() }
single<AuthSession> {
AuthSession(
oidcClient = get<OidcClient>(),
store = get<SecureAuthStateStore>(),
meClient = get<MeClient>(),
)
}
single<HttpClient> { AuthHttpClient.create(get()) }
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that
// owns AuthSession also owns its UI consumers; Koin scopes them per Composable.
viewModel { LoginViewModel(authSession = get()) }
viewModel { PostLoginViewModel(authSession = get()) }
}

View File

@@ -0,0 +1,176 @@
package dev.ulfrx.recipe.auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
interface OidcClientGateway {
suspend fun login(): OidcResult
suspend fun refresh(authStateJson: String): OidcResult
suspend fun logout(authStateJson: String)
}
interface AuthStateStore {
fun read(): String?
fun write(authStateJson: String)
fun clear()
}
sealed interface AuthLoginResult {
data object Success : AuthLoginResult
data object Cancelled : AuthLoginResult
data object NetworkError : AuthLoginResult
data class Failed(
val message: String,
) : AuthLoginResult
}
class AuthSession(
private val oidcClient: OidcClientGateway,
private val store: AuthStateStore,
private val meClient: MeGateway,
) {
constructor(
oidcClient: OidcClient,
store: SecureAuthStateStore,
meClient: MeClient,
) : this(
oidcClient =
object : OidcClientGateway {
override suspend fun login(): OidcResult = oidcClient.login()
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
override suspend fun logout(authStateJson: String) {
oidcClient.logout(authStateJson)
}
},
store =
object : AuthStateStore {
override fun read(): String? = store.read()
override fun write(authStateJson: String) {
store.write(authStateJson)
}
override fun clear() {
store.clear()
}
},
meClient = meClient,
)
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
val state: StateFlow<AuthState> = _state.asStateFlow()
private var currentTokens: BearerTokens? = null
suspend fun initialize() {
_state.value = AuthState.Loading
val storedJson = store.read()
if (storedJson.isNullOrBlank()) {
clearSession()
return
}
when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> authenticate(refreshResult)
OidcResult.Cancelled,
OidcResult.NetworkError,
is OidcResult.AuthError,
-> clearSession()
}
}
suspend fun login(): AuthLoginResult =
when (val loginResult = oidcClient.login()) {
is OidcResult.Success -> {
authenticate(loginResult)
AuthLoginResult.Success
}
OidcResult.Cancelled -> {
_state.value = AuthState.Unauthenticated
AuthLoginResult.Cancelled
}
OidcResult.NetworkError -> {
_state.value = AuthState.Unauthenticated
AuthLoginResult.NetworkError
}
is OidcResult.AuthError -> {
_state.value = AuthState.Unauthenticated
AuthLoginResult.Failed(loginResult.message)
}
}
suspend fun logout() {
val storedJson = store.read()
if (!storedJson.isNullOrBlank()) {
runCatching {
oidcClient.logout(storedJson)
}
}
clearSession()
}
suspend fun getAccessToken(): String? = refreshBearerTokens()?.accessToken
fun currentBearerTokens(): BearerTokens? = currentTokens
suspend fun refreshBearerTokens(): BearerTokens? {
val storedJson =
store.read() ?: return null.also {
clearSession()
}
return when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> {
persistTokens(refreshResult)
currentTokens
}
OidcResult.Cancelled,
OidcResult.NetworkError,
is OidcResult.AuthError,
-> {
null.also {
clearSession()
}
}
}
}
private suspend fun authenticate(result: OidcResult.Success) {
persistTokens(result)
val user = meClient.getMe(result.accessToken)
_state.value = AuthState.Authenticated(user = user, householdId = null)
}
private fun persistTokens(result: OidcResult.Success) {
store.write(result.authStateJson)
currentTokens =
BearerTokens(
accessToken = result.accessToken,
refreshToken = result.authStateJson,
)
}
private fun clearSession() {
currentTokens = null
store.clear()
_state.value = AuthState.Unauthenticated
}
}

View File

@@ -0,0 +1,16 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User
typealias HouseholdId = String
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null,
) : AuthState()
}

View File

@@ -0,0 +1,41 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.Constants
import dev.ulfrx.recipe.shared.dto.User
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
interface MeGateway {
suspend fun getMe(accessToken: String? = null): User
}
class MeClient(
private val httpClient: HttpClient =
HttpClient {
install(ContentNegotiation) {
json(authJson)
}
},
) : MeGateway {
override suspend fun getMe(accessToken: String?): User =
httpClient
.get("${Constants.API_BASE_URL}api/v1/me") {
if (!accessToken.isNullOrBlank()) {
header(HttpHeaders.Authorization, "Bearer ".plus(accessToken))
}
}.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
.toUser()
private companion object {
val authJson =
Json {
ignoreUnknownKeys = true
}
}
}

View File

@@ -0,0 +1,24 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
/**
* Common seam for Authentik OIDC.
*
* Native Android/iOS actuals use Lokksmith for browser-based Authorization Code
* + PKCE. Login requests must be public PKCE-compatible OIDC requests with
* exactly these scopes: `openid profile email offline_access` (D-06).
* Lokksmith owns state and nonce verification.
*
* Refresh must go through Lokksmith fresh-token handling, then return an opaque
* auth-state marker for persistence (D-16). Logout must use RP-initiated
* end-session before local state is cleared; callers still clear local state if
* remote logout fails so users are never trapped in a stale session (D-19, D-20).
*/
expect class OidcClient() {
suspend fun login(): OidcResult
suspend fun refresh(authStateJson: String): OidcResult
suspend fun logout(authStateJson: String)
}

View File

@@ -0,0 +1,25 @@
package dev.ulfrx.recipe.auth
/**
* Result returned by platform OIDC clients.
*
* `authStateJson` is an opaque platform-auth marker persisted by [SecureAuthStateStore].
* Callers must not parse token values out of it directly.
*/
sealed interface OidcResult {
data class Success(
val authStateJson: String,
val accessToken: String,
val idToken: String?,
val expiresAtEpochMillis: Long,
) : OidcResult
data object Cancelled : OidcResult
data object NetworkError : OidcResult
data class AuthError(
val message: String,
val cause: Throwable? = null,
) : OidcResult
}

View File

@@ -0,0 +1,19 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
/**
* Persists the opaque platform auth state marker for the current app install.
*
* Mobile actuals must use explicit secure platform storage for token material
* (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use
* no-arg or default insecure settings implementations for auth state. The stored
* value is global to the install and must be deleted on logout (D-15).
*/
expect class SecureAuthStateStore() {
fun read(): String?
fun write(authStateJson: String)
fun clear()
}

View File

@@ -1,9 +1,10 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.authModule
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
// intentionally empty in Phase 1
includes(authModule)
}

View File

@@ -0,0 +1,88 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
import recipe.composeapp.generated.resources.auth_sign_in_button
/**
* Visible during [dev.ulfrx.recipe.auth.AuthState.Unauthenticated]. Wordmark + sign-in
* button + inline error text (when present). Inline-error UX rules and loading rules
* locked in `02-UI-SPEC.md` § Copywriting Contract.
*/
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
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_app_name),
style = MaterialTheme.typography.displaySmall,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { viewModel.onSignInClick() },
enabled = !state.isLoading,
) {
if (state.isLoading) {
Box(
modifier = Modifier.size(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
}
} else {
Text(text = stringResource(Res.string.auth_sign_in_button))
}
}
val errorKey = state.errorKey
if (errorKey != null) {
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(errorKey),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}
}

View File

@@ -0,0 +1,63 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_error_cancelled
import recipe.composeapp.generated.resources.auth_error_network
import recipe.composeapp.generated.resources.auth_error_unknown
/**
* Immutable UI state for [LoginScreen]. The [errorKey] is a Compose Resources
* [StringResource] handle, not a translated string — the screen resolves it via
* `stringResource(...)` so the ViewModel stays platform/locale agnostic.
*/
data class LoginScreenState(
val isLoading: Boolean = false,
val errorKey: StringResource? = null,
)
/**
* Wraps [AuthSession] to drive the LoginScreen. Method-per-action: [onSignInClick] is the
* single entry point. Cancellation/network/unknown failures map to user-facing string
* resources per `02-UI-SPEC.md` § Copywriting Contract.
*
* Returns the launched [Job] from [onSignInClick] so tests can deterministically await
* completion without dragging a TestDispatcher into commonTest.
*/
class LoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
fun onSignInClick(): Job {
// Clear any previous inline error and enter the loading state before suspending —
// contract from UI-SPEC: tapping the button again clears stale error text.
_state.value = LoginScreenState(isLoading = true, errorKey = null)
return viewModelScope.launch {
val result = authSession.login()
_state.value =
LoginScreenState(
isLoading = false,
errorKey = result.toErrorKeyOrNull(),
)
}
}
private fun AuthLoginResult.toErrorKeyOrNull(): StringResource? =
when (this) {
AuthLoginResult.Success -> null
AuthLoginResult.Cancelled -> Res.string.auth_error_cancelled
AuthLoginResult.NetworkError -> Res.string.auth_error_network
is AuthLoginResult.Failed -> Res.string.auth_error_unknown
}
}

View File

@@ -0,0 +1,57 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.shared.dto.User
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_sign_out_button
import recipe.composeapp.generated.resources.auth_welcome_format
/**
* Phase 2 placeholder: welcome message + logout. Phase 3 replaces this with `HouseholdGate`.
*/
@Composable
fun PostLoginPlaceholderScreen(
user: User,
viewModel: PostLoginViewModel,
) {
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,
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { viewModel.onSignOutClick() }) {
Text(text = stringResource(Res.string.auth_sign_out_button))
}
}
}
}

View File

@@ -0,0 +1,21 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.launch
/**
* Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern.
* Logout is silent (CONTEXT D-19): no confirmation modal; tap immediately initiates
* RP-initiated end-session via [AuthSession.logout].
*/
class PostLoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
fun onSignOutClick() {
viewModelScope.launch {
authSession.logout()
}
}
}

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.ui.screens.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
/**
* Visible during [dev.ulfrx.recipe.auth.AuthState.Loading]. Wordmark + circular progress.
* No marketing copy, no tagline. Background is `surface` so the Login transition has no
* color flash.
*/
@Composable
fun SplashScreen() {
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_app_name),
style = MaterialTheme.typography.displaySmall,
)
Spacer(Modifier.height(8.dp))
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
}
}
}

View File

@@ -0,0 +1,35 @@
package dev.ulfrx.recipe.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Phase 2 seed theme. Material 3 light/dark schemes with a single seed override on `primary`
* (`#3B6939` light / `#A2D597` dark — see `02-UI-SPEC.md` § Color). All other roles use
* Material 3 baseline values. Phase 11 may rebase the palette around a different seed.
*
* Intentionally minimal: no Haze, no custom typography, no shapes. Per UI-SPEC, Material 3
* defaults satisfy Phase 2's spacing/typography/accessibility contract.
*/
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,
)
}