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() ContactScreen()
} }
composable(Routes.LOGIN) { 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) { composable(Routes.ACCOUNT) {
// Only meaningful while signed in; a sign-out (here or from the drawer) // 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -15,14 +17,20 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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 // ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the
// Custom-Tab bridge and returns the user signed in. No website-login // Custom-Tab bridge. With one provider it launches straight through; with
// fallback — that page isn't mobile-formatted and can't deep-link the // several it presents a native picker (below). No website-login fallback —
// session back; a failed discovery offers a retry instead. // that page can't deep-link the session back; a failed discovery offers a retry.
var showSsoPicker by remember { mutableStateOf(false) }
when { when {
state.ssoProviders.isNotEmpty() -> { state.ssoProviders.isNotEmpty() -> {
state.ssoProviders.forEach { provider -> OutlinedButton(
OutlinedButton( onClick = {
onClick = { viewModel.onSsoProviderClick(provider) }, val providers = state.ssoProviders
enabled = !state.submitting, if (providers.size == 1) viewModel.onSsoProviderClick(providers.first())
modifier = Modifier else showSsoPicker = true
.fillMaxWidth() },
.padding(top = 12.dp), enabled = !state.submitting,
) { modifier = Modifier
Text(stringResource(R.string.login_sso_provider, provider.name)) .fillMaxWidth()
} .padding(top = 12.dp),
) {
Text(stringResource(R.string.login_sso_button))
} }
} }
@@ -214,6 +225,53 @@ fun LoginScreen(
Text(stringResource(R.string.login_forgot)) 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_button">Sign in</string>
<string name="login_register">Create an account</string> <string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</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_provider">Sign in with %1$s</string>
<string name="login_sso_loading">Loading sign-in options…</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> <string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>