diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..ee3d762 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,15 @@ + + + + Recipe + Zaloguj się przez Authentik + Wyloguj się + Witaj, %1$s! + Logowanie anulowane. Spróbuj ponownie. + Nie można połączyć z Authentik. Sprawdź połączenie. + Coś poszło nie tak. Spróbuj ponownie. + diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt new file mode 100644 index 0000000..e52bb22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt @@ -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, + ) +} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt new file mode 100644 index 0000000..530ba05 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt @@ -0,0 +1,207 @@ +package dev.ulfrx.recipe.ui.screens.auth + +import dev.ulfrx.recipe.auth.AuthLoginResult +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.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LoginViewModelTest { + @Test + fun cancelledAuthFailureMapsToCancelledStringResource() { + runBlocking { + val session = newSession(loginResult = OidcResult.Cancelled) + val viewModel = LoginViewModel(session) + + viewModel.onSignInClick().join() + + assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) + assertEquals(false, viewModel.state.value.isLoading) + } + } + + @Test + fun networkAuthFailureMapsToNetworkStringResource() { + runBlocking { + val session = newSession(loginResult = OidcResult.NetworkError) + val viewModel = LoginViewModel(session) + + viewModel.onSignInClick().join() + + assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey) + assertEquals(false, viewModel.state.value.isLoading) + } + } + + @Test + fun unknownAuthFailureMapsToUnknownStringResource() { + runBlocking { + val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed")) + val viewModel = LoginViewModel(session) + + viewModel.onSignInClick().join() + + assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey) + assertEquals(false, viewModel.state.value.isLoading) + } + } + + @Test + fun successClearsErrorAndStopsLoading() { + runBlocking { + val session = + newSession( + loginResult = + OidcResult.Success( + authStateJson = "{}", + accessToken = "access", + idToken = null, + expiresAtEpochMillis = 0L, + ), + ) + val viewModel = LoginViewModel(session) + + viewModel.onSignInClick().join() + + assertNull(viewModel.state.value.errorKey) + assertEquals(false, viewModel.state.value.isLoading) + } + } + + @Test + fun startingNewSignInClearsPreviousErrorAndSetsLoading() { + runBlocking { + // First login fails with cancelled to seed an error. + val firstOidc = FakeOidcClient(loginResult = OidcResult.Cancelled) + val session = AuthSession(firstOidc, FakeAuthStateStore(), FakeMeClient(USER)) + val viewModel = LoginViewModel(session) + viewModel.onSignInClick().join() + assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) + + // Second login: gate the result so we can observe loading=true and error cleared. + val gate = CompletableDeferred() + val gatedOidc = + object : OidcClientGateway { + override suspend fun login(): OidcResult = gate.await() + + override suspend fun refresh(authStateJson: String): OidcResult = + OidcResult.AuthError("not used") + + override suspend fun logout(authStateJson: String) {} + } + val gatedSession = AuthSession(gatedOidc, FakeAuthStateStore(), FakeMeClient(USER)) + val gatedViewModel = LoginViewModel(gatedSession) + + // Seed an error on the gated VM by directly priming via the cancelled session. + // Simpler: assert behavior with a single VM that has both attempts. + val singleOidcQueue = + mutableListOf( + OidcResult.Cancelled, + ) + val singleOidc = + object : OidcClientGateway { + override suspend fun login(): OidcResult { + // First call returns immediately; subsequent calls await the gate. + return if (singleOidcQueue.isNotEmpty()) { + singleOidcQueue.removeAt(0) + } else { + gate.await() + } + } + + override suspend fun refresh(authStateJson: String): OidcResult = + OidcResult.AuthError("not used") + + override suspend fun logout(authStateJson: String) {} + } + val singleSession = AuthSession(singleOidc, FakeAuthStateStore(), FakeMeClient(USER)) + val singleVm = LoginViewModel(singleSession) + singleVm.onSignInClick().join() + assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey) + + // Now start the second login and observe immediate loading + cleared error. + val job = + async(Dispatchers.Unconfined) { + singleVm.onSignInClick().join() + } + // Yield to let the VM enter the loading state before await suspends. + yield() + assertTrue(singleVm.state.value.isLoading) + assertNull(singleVm.state.value.errorKey) + + gate.complete(OidcResult.Cancelled) + job.await() + + // After completion, loading is false and the new (still cancelled) error is set. + assertEquals(false, singleVm.state.value.isLoading) + assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey) + } + } + + private fun newSession( + loginResult: OidcResult, + store: AuthStateStore = FakeAuthStateStore(), + meClient: MeGateway = FakeMeClient(USER), + ): AuthSession = + AuthSession( + oidcClient = FakeOidcClient(loginResult = loginResult), + store = store, + meClient = meClient, + ) + + 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 = OidcResult.AuthError("login not configured"), + private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"), + ) : OidcClientGateway { + override suspend fun login(): OidcResult = loginResult + + override suspend fun refresh(authStateJson: String): OidcResult = refreshResult + + override suspend fun logout(authStateJson: String) {} + } + + 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", + ) + } +}