diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 43afa1c..1f17854 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -320,7 +320,15 @@ private fun RunicNavHost( ContactScreen() } composable(Routes.LOGIN) { - LoginScreen(onSignedIn = { navController.popBackStack() }) + // Leave the login screen as soon as the session is established — whether by + // password or the SSO bridge. Keying off the shared session (not just the + // login VM's local flag) makes this robust to the deep-link/recomposition + // timing of the Custom-Tab return, which the LoginScreen callback alone can miss. + if (session is Session.SignedIn) { + LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) } + } else { + LoginScreen(onSignedIn = { navController.popBackStack() }) + } } composable(Routes.ACCOUNT) { // Only meaningful while signed in; a sign-out (here or from the drawer) diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt index cc1159f..286d95b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt @@ -5,8 +5,10 @@ package com.runicgateway.app.ui.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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -15,14 +17,20 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState 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.platform.LocalContext @@ -163,22 +171,25 @@ fun LoginScreen( } } - // ── Native SSO (§4.2, M9): a button per enabled provider that opens the - // Custom-Tab bridge and returns the user signed in. No website-login - // fallback — that page isn't mobile-formatted and can't deep-link the - // session back; a failed discovery offers a retry instead. + // ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the + // Custom-Tab bridge. With one provider it launches straight through; with + // several it presents a native picker (below). No website-login fallback — + // that page can't deep-link the session back; a failed discovery offers a retry. + var showSsoPicker by remember { mutableStateOf(false) } when { state.ssoProviders.isNotEmpty() -> { - state.ssoProviders.forEach { provider -> - OutlinedButton( - onClick = { viewModel.onSsoProviderClick(provider) }, - enabled = !state.submitting, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) { - Text(stringResource(R.string.login_sso_provider, provider.name)) - } + OutlinedButton( + onClick = { + val providers = state.ssoProviders + if (providers.size == 1) viewModel.onSsoProviderClick(providers.first()) + else showSsoPicker = true + }, + enabled = !state.submitting, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + Text(stringResource(R.string.login_sso_button)) } } @@ -214,6 +225,53 @@ fun LoginScreen( Text(stringResource(R.string.login_forgot)) } } + + if (showSsoPicker) { + SsoProviderPicker( + providers = state.ssoProviders, + onDismiss = { showSsoPicker = false }, + onPick = { provider -> + showSsoPicker = false + viewModel.onSsoProviderClick(provider) + }, + ) + } + } +} + +/** + * The native provider picker (§4.2): a bottom sheet listing the shard's enabled SSO + * providers so a single "Sign in with SSO" button can serve several IdPs without a + * website chooser page. Each row opens the Custom-Tab bridge for that provider. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SsoProviderPicker( + providers: List, + onDismiss: () -> Unit, + onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Text( + text = stringResource(R.string.login_sso_pick_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + providers.forEach { provider -> + TextButton( + onClick = { onPick(provider) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + ) { + Text( + text = stringResource(R.string.login_sso_provider, provider.name), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Start, + ) + } + } + Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 02c3acd..77e38ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,7 +142,10 @@ Sign in Create an account Forgot your password? - + + Sign in with SSO + Choose a sign-in provider + Sign in with %1$s Loading sign-in options… Couldn\'t load sign-in options. Tap to retry.