diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt index 6243243..d5fda9a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt @@ -1,53 +1,48 @@ 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.lifecycle.compose.collectAsStateWithLifecycle 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 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!") - } - AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") - } - } + RecipeTheme { + val authSession = koinInject() + 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() + AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel()) + is AuthState.Authenticated -> + PostLoginPlaceholderScreen( + user = current.user, + viewModel = koinViewModel(), + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt index 41646be..1596370 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt @@ -1,6 +1,9 @@ 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 = @@ -16,4 +19,9 @@ val authModule = ) } single { 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()) } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt new file mode 100644 index 0000000..afc4266 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt @@ -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, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt new file mode 100644 index 0000000..cf11aa2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt @@ -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 = _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 + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt new file mode 100644 index 0000000..80aa357 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt @@ -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)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt new file mode 100644 index 0000000..6646444 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt @@ -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() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt new file mode 100644 index 0000000..2b96cbe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt @@ -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, + ) + } + } +}