Add preparing navigation to roadmap

This commit is contained in:
2026-05-07 22:51:01 +02:00
parent 95bbeb57d2
commit f7e866a08d
27 changed files with 402 additions and 3629 deletions

View File

@@ -13,20 +13,25 @@ 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 dev.ulfrx.recipe.user.UserRepository
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`.
* Two-layer gate: [AuthSession] tells us whether tokens exist; [UserRepository]
* tells us who the authenticated principal is in the app's data model. While
* tokens are present but the `/me` fetch hasn't returned yet, we hold the
* splash so the user never sees an empty post-login screen. Phase 3 replaces
* the `Authenticated + user` branch with `HouseholdGate`.
*/
@Composable
@Preview
fun App() {
RecipeTheme {
val authSession = koinInject<AuthSession>()
val userRepository = koinInject<UserRepository>()
val authState by authSession.state.collectAsStateWithLifecycle()
val currentUser by userRepository.currentUser.collectAsStateWithLifecycle()
// Kick off the persisted-session restore once. AuthSession.initialize()
// refreshes the stored AuthState (or transitions to Unauthenticated on
@@ -35,20 +40,21 @@ fun App() {
authSession.initialize()
}
when (val current = authState) {
AuthState.Loading -> {
SplashScreen()
}
when (authState) {
AuthState.Loading -> SplashScreen()
AuthState.Unauthenticated -> {
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
}
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
is AuthState.Authenticated -> {
PostLoginPlaceholderScreen(
user = current.user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
AuthState.Authenticated -> {
val user = currentUser
if (user == null) {
SplashScreen()
} else {
PostLoginPlaceholderScreen(
user = user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
}
}

View File

@@ -4,15 +4,18 @@ import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import io.ktor.client.HttpClient
import org.koin.dsl.module
import org.koin.plugin.module.dsl.single
import org.koin.plugin.module.dsl.viewModel
val authModule =
module {
single<SecureAuthStateStore>()
single<OidcClient>()
single<MeClient>()
single<AuthSession>()
single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
single<OidcClient> { OidcClient(get()) }
single<AuthSession> {
AuthSession(
oidcClient = get<OidcClient>(),
store = get<SecureAuthStateStore>(),
)
}
single<HttpClient> { AuthHttpClient.create(get()) }
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that

View File

@@ -33,15 +33,18 @@ sealed interface AuthLoginResult {
) : AuthLoginResult
}
/**
* Owns *just* the authentication state machine: tokens, refresh, logout.
* User profile fetch lives in [dev.ulfrx.recipe.user.UserRepository], which
* observes [state] and reacts to transitions.
*/
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 {
@@ -65,7 +68,6 @@ class AuthSession(
store.clear()
}
},
meClient = meClient,
)
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
@@ -83,7 +85,7 @@ class AuthSession(
}
when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> authenticate(refreshResult)
is OidcResult.Success -> persistAndAuthenticate(refreshResult)
OidcResult.Cancelled,
OidcResult.NetworkError,
@@ -95,7 +97,7 @@ class AuthSession(
suspend fun login(browser: AuthBrowser): AuthLoginResult =
when (val loginResult = oidcClient.login(browser)) {
is OidcResult.Success -> {
authenticate(loginResult)
persistAndAuthenticate(loginResult)
AuthLoginResult.Success
}
@@ -153,10 +155,9 @@ class AuthSession(
}
}
private suspend fun authenticate(result: OidcResult.Success) {
private fun persistAndAuthenticate(result: OidcResult.Success) {
persistTokens(result)
val user = meClient.getMe(result.accessToken)
_state.value = AuthState.Authenticated(user = user, householdId = null)
_state.value = AuthState.Authenticated
}
private fun persistTokens(result: OidcResult.Success) {

View File

@@ -1,16 +1,15 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User
typealias HouseholdId = String
/**
* Pure authentication state — token-bearing or not. User profile (display name,
* email, server-issued id, household membership) lives behind
* [dev.ulfrx.recipe.user.UserRepository] and is loaded after auth flips to
* [Authenticated]. Screens that need a user observe both flows.
*/
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null,
) : AuthState()
data object Authenticated : AuthState()
}

View File

@@ -1,41 +0,0 @@
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

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

View File

@@ -0,0 +1,23 @@
package dev.ulfrx.recipe.user
import dev.ulfrx.recipe.shared.Constants
import dev.ulfrx.recipe.shared.dto.MeResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import org.koin.dsl.module
val userModule =
module {
single<UserRepository> {
UserRepository(
authSession = get(),
fetchUser = {
get<HttpClient>()
.get("${Constants.API_BASE_URL}api/v1/me")
.body<MeResponse>()
.toUser()
},
)
}
}

View File

@@ -0,0 +1,57 @@
package dev.ulfrx.recipe.user
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Owns the current authenticated user as observable state.
*
* Subscribes to [AuthSession.state] for life: when auth flips to
* [AuthState.Authenticated] it fetches `/me` via [fetchUser] once and emits
* the result through [currentUser]. On [AuthState.Unauthenticated] it clears
* [currentUser] so screens drop back to the login gate cleanly.
*
* The fetch is a `suspend () -> User` lambda rather than a wrapped gateway:
* one consumer, one impl, no interface needed. When Phase 4 introduces a
* SyncEngine with local + remote sources, extract the seam then.
*/
class UserRepository(
private val authSession: AuthSession,
private val fetchUser: suspend () -> User,
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
init {
scope.launch {
authSession.state
.collect { state ->
when (state) {
is AuthState.Authenticated -> {
if (_currentUser.value == null) {
runCatching { fetchUser() }
.onSuccess { user -> _currentUser.value = user }
}
}
AuthState.Unauthenticated -> _currentUser.value = null
AuthState.Loading -> Unit
}
}
}
}
suspend fun refresh() {
runCatching { fetchUser() }
.onSuccess { user -> _currentUser.value = user }
}
}

View File

@@ -2,7 +2,6 @@ package dev.ulfrx.recipe.auth
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -24,7 +23,7 @@ class AuthSessionTest {
}
@Test
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
fun successfulLoginPersistsAuthStateAndEmitsAuthenticated() {
runTest {
val store = FakeAuthStateStore()
val oidcClient =
@@ -37,22 +36,18 @@ class AuthSessionTest {
expiresAtEpochMillis = 123_456L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
val session = newSession(store = store, oidcClient = oidcClient)
val result = session.login(NoopBrowser)
assertEquals(AuthLoginResult.Success, result)
assertEquals(AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
assertIs<AuthState.Authenticated>(session.state.value)
}
}
@Test
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
fun existingStoreRefreshesAndEmitsAuthenticatedWithoutLogin() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
@@ -65,18 +60,14 @@ class AuthSessionTest {
expiresAtEpochMillis = 789_000L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
val session = newSession(store = store, oidcClient = oidcClient)
session.initialize()
assertEquals(emptyList(), oidcClient.loginCalls)
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(REFRESHED_ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
assertIs<AuthState.Authenticated>(session.state.value)
}
}
@@ -165,12 +156,10 @@ class AuthSessionTest {
private fun newSession(
store: AuthStateStore = FakeAuthStateStore(),
oidcClient: OidcClientGateway = FakeOidcClient(),
meClient: MeGateway = FakeMeClient(user = USER),
): AuthSession =
AuthSession(
oidcClient = oidcClient,
store = store,
meClient = meClient,
)
private object NoopBrowser : AuthBrowser {
@@ -219,29 +208,10 @@ class AuthSessionTest {
}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
val accessTokens = mutableListOf<String?>()
override suspend fun getMe(accessToken: String?): User {
accessTokens += accessToken
return user
}
}
private companion object {
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
const val ACCESS_TOKEN = "access-token"
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -5,10 +5,8 @@ import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.MeGateway
import dev.ulfrx.recipe.auth.OidcClientGateway
import dev.ulfrx.recipe.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import recipe.composeapp.generated.resources.Res
@@ -95,7 +93,7 @@ class LoginViewModelTest {
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
}
val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER))
val session = AuthSession(oidc, FakeAuthStateStore())
val viewModel = LoginViewModel(session)
// First attempt: error seeded.
@@ -120,12 +118,10 @@ class LoginViewModelTest {
private fun newSession(
loginResult: OidcResult,
store: AuthStateStore = FakeAuthStateStore(),
meClient: MeGateway = FakeMeClient(USER),
): AuthSession =
AuthSession(
oidcClient = FakeOidcClient(loginResult = loginResult),
store = store,
meClient = meClient,
)
private object NoopBrowser : AuthBrowser {
@@ -157,20 +153,4 @@ class LoginViewModelTest {
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
override suspend fun getMe(accessToken: String?): User = user
}
private companion object {
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -0,0 +1,128 @@
package dev.ulfrx.recipe.user
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.OidcClientGateway
import dev.ulfrx.recipe.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class UserRepositoryTest {
@Test
fun fetchesUserWhenAuthFlipsToAuthenticated() =
runTest {
val session = newSession()
var fetchCount = 0
val repository =
UserRepository(
authSession = session,
fetchUser = { fetchCount++; USER },
scope = TestScope(testScheduler),
)
session.login(NoopBrowser)
val user = repository.currentUser.first { it != null }
assertEquals(USER, user)
assertEquals(1, fetchCount)
}
@Test
fun clearsUserOnLogout() =
runTest {
val session = newSession()
val repository =
UserRepository(
authSession = session,
fetchUser = { USER },
scope = TestScope(testScheduler),
)
session.login(NoopBrowser)
repository.currentUser.first { it != null }
session.logout(NoopBrowser)
val cleared = repository.currentUser.firstOrNull { it == null }
assertNull(cleared)
}
@Test
fun networkFailureLeavesCurrentUserNullWithoutCrashing() =
runTest {
val session = newSession()
val repository =
UserRepository(
authSession = session,
fetchUser = { error("network down") },
scope = TestScope(testScheduler),
)
session.login(NoopBrowser)
testScheduler.advanceUntilIdle()
assertNull(repository.currentUser.value)
}
private fun newSession(): AuthSession =
AuthSession(
oidcClient = FakeOidcClient(loginResult = SUCCESS),
store = FakeAuthStateStore(),
)
private object NoopBrowser : AuthBrowser {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
AuthFlowResultProvider.Result.Undefined
}
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
override fun read(): String? = value
override fun write(authStateJson: String) {
value = authStateJson
}
override fun clear() {
value = null
}
}
private class FakeOidcClient(
private val loginResult: OidcResult,
) : OidcClientGateway {
override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
}
private companion object {
val SUCCESS =
OidcResult.Success(
authStateJson = "{}",
accessToken = "access",
idToken = null,
expiresAtEpochMillis = 0L,
)
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}