feat(sso): single "Sign in with SSO" button with a native provider picker
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m10s
Release APK / release (push) Successful in 9m26s

Collapse the per-provider login buttons into one "Sign in with SSO" entry. With a
single configured provider it launches straight through; with several it opens a
native ModalBottomSheet picker (driven by the discovery list the app already
fetches — no website chooser page, no Google SDK). Each row opens the Custom-Tab
bridge for that provider.

Also make the login screen dismiss reliably after any sign-in: the LOGIN
destination now pops as soon as the shared session becomes SignedIn, not only via
the login VM's local flag — the deep-link/recomposition timing of the Custom-Tab
return could otherwise leave the login screen up even though the session was
established.

Verified on emulator with two providers: the picker lists both, completing SSO via
one signs in and returns to Home (exchange 200, session persisted). lint + build green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:28:32 -05:00
parent 7f876377f0
commit 422892f1ed
3 changed files with 85 additions and 16 deletions

View File

@@ -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)

View File

@@ -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<com.runicgateway.app.data.api.dto.SsoProviderDto>,
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
}
}

View File

@@ -142,7 +142,10 @@
<string name="login_button">Sign in</string>
<string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). -->
<!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). -->
<string name="login_sso_button">Sign in with SSO</string>
<string name="login_sso_pick_title">Choose a sign-in provider</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
<string name="login_sso_provider">Sign in with %1$s</string>
<string name="login_sso_loading">Loading sign-in options…</string>
<string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>