feat(android): Phase 11 Commit B — ServerUrl entry + auth-gated startDestination
Cold launch now lands on the right screen based on persisted auth
state instead of always starting at Home.
New:
- auth/ui/AuthGateViewModel.kt — one-shot StateFlow that resolves
the initial NavHost destination from AuthSessionDao directly
(not AuthStore.sessionCookie, which is null until Room's first
async emission). Three outcomes:
no row at all → ServerUrl
row with default URL and no cookie → ServerUrl
row with cookie absent / empty → Login
row with cookie present → Home
- auth/ui/ServerUrlScreen.kt — VM + Screen in one file (still under
the function-count cap). Validates `http(s)://` prefix +
non-empty, trims trailing slash before persisting. On success
navigates to Login and pops ServerUrl off the back stack.
Modified:
- MainActivity.kt — wraps the NavGraph in an App() composable that
reads the AuthGateViewModel. Renders a centered spinner
(BootSplash) while the gate resolves; once resolved, hands the
decision to MinstrelNavGraph as `startDestination`.
- nav/MinstrelNavGraph.kt — startDestination is now a parameter
(was hardcoded to Home). ServerUrl route renders the real screen
instead of ComingSoon.
Settings (with sign-out) is Commit C; cross-restart user identity
persistence + auth-error 401 redirect handling are later refinements.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,18 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -24,12 +32,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun App() {
|
private fun App(gate: AuthGateViewModel = hiltViewModel()) {
|
||||||
// No bottom nav, no drawer — navigation is per-screen via the
|
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
||||||
// AppBar actions row + kebab (`MainAppBarActions`). The MiniPlayer
|
val resolved = startDestination
|
||||||
// is included by each in-shell route's `ShellScaffold` wrap;
|
if (resolved == null) {
|
||||||
// full-screen routes (NowPlaying / Queue / unauthenticated) bypass
|
BootSplash()
|
||||||
// the shell entirely.
|
} else {
|
||||||
val navController = rememberNavController()
|
// No bottom nav, no drawer — navigation is per-screen via the
|
||||||
MinstrelNavGraph(navController = navController, modifier = Modifier.fillMaxSize())
|
// AppBar actions row + kebab (`MainAppBarActions`). The MiniPlayer
|
||||||
|
// is included by each in-shell route's `ShellScaffold` wrap;
|
||||||
|
// full-screen routes (NowPlaying / Queue / unauthenticated)
|
||||||
|
// bypass the shell entirely.
|
||||||
|
val navController = rememberNavController()
|
||||||
|
MinstrelNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = resolved,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BootSplash() {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.fabledsword.minstrel.auth.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
|
import com.fabledsword.minstrel.nav.Home
|
||||||
|
import com.fabledsword.minstrel.nav.Login
|
||||||
|
import com.fabledsword.minstrel.nav.ServerUrl
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the initial startDestination for the root NavHost based on
|
||||||
|
* persisted auth state. Sits on top of AuthSessionDao directly rather
|
||||||
|
* than AuthStore's StateFlow because the StateFlow defaults to null
|
||||||
|
* until Room's first async emission — we need a definitive answer
|
||||||
|
* before drawing any nav graph.
|
||||||
|
*
|
||||||
|
* - no row at all → ServerUrl (first launch)
|
||||||
|
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||||
|
* - row with cookie → Home (signed in)
|
||||||
|
*
|
||||||
|
* `startDestination` emits null while resolving (UI shows a spinner)
|
||||||
|
* and then exactly one resolved value.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthGateViewModel @Inject constructor(
|
||||||
|
private val dao: AuthSessionDao,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow<Any?>(null)
|
||||||
|
val startDestination: StateFlow<Any?> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val row = dao.get()
|
||||||
|
internal.value = when {
|
||||||
|
row == null -> ServerUrl
|
||||||
|
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
|
||||||
|
ServerUrl
|
||||||
|
row.sessionCookie.isNullOrEmpty() -> Login
|
||||||
|
else -> Home
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package com.fabledsword.minstrel.auth.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.nav.Login
|
||||||
|
import com.fabledsword.minstrel.nav.ServerUrl
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// ─── State + VM ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class ServerUrlFormState(
|
||||||
|
val url: String = "",
|
||||||
|
val saving: Boolean = false,
|
||||||
|
val saved: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ServerUrlViewModel @Inject constructor(
|
||||||
|
private val authStore: AuthStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow(
|
||||||
|
ServerUrlFormState(url = authStore.baseUrl.value),
|
||||||
|
)
|
||||||
|
val state: StateFlow<ServerUrlFormState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
fun setUrl(value: String) {
|
||||||
|
internal.update { it.copy(url = value, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
val raw = internal.value.url.trim().trimEnd('/')
|
||||||
|
if (raw.isEmpty()) {
|
||||||
|
internal.update { it.copy(errorMessage = "Enter a server URL.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!raw.startsWith("http://") && !raw.startsWith("https://")) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(errorMessage = "URL must start with http:// or https://")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.update { it.copy(saving = true, errorMessage = null) }
|
||||||
|
authStore.setBaseUrl(raw)
|
||||||
|
internal.update { it.copy(saving = false, saved = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeSaved() {
|
||||||
|
internal.update { it.copy(saved = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ServerUrlScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: ServerUrlViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(state.saved) {
|
||||||
|
if (state.saved) {
|
||||||
|
navController.navigate(Login) {
|
||||||
|
popUpTo(ServerUrl) { inclusive = true }
|
||||||
|
}
|
||||||
|
viewModel.consumeSaved()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ServerUrlForm(
|
||||||
|
state = state,
|
||||||
|
onUrlChange = viewModel::setUrl,
|
||||||
|
onSubmit = viewModel::submit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerUrlForm(
|
||||||
|
state: ServerUrlFormState,
|
||||||
|
onUrlChange: (String) -> Unit,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Connect to your Minstrel",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Enter the URL of your Minstrel server.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.url,
|
||||||
|
onValueChange = onUrlChange,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Server URL") },
|
||||||
|
placeholder = { Text("https://minstrel.example.com") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.saving,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
|
||||||
|
)
|
||||||
|
if (state.errorMessage != null) {
|
||||||
|
Text(
|
||||||
|
text = state.errorMessage,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onSubmit,
|
||||||
|
enabled = !state.saving && state.url.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Continue") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
|||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||||
|
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||||
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
|
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
|
||||||
import com.fabledsword.minstrel.home.ui.HomeScreen
|
import com.fabledsword.minstrel.home.ui.HomeScreen
|
||||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
@@ -36,12 +37,13 @@ import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
|||||||
@Composable
|
@Composable
|
||||||
fun MinstrelNavGraph(
|
fun MinstrelNavGraph(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
|
startDestination: Any,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val expandPlayer = { navController.navigate(NowPlaying) }
|
val expandPlayer = { navController.navigate(NowPlaying) }
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Home,
|
startDestination = startDestination,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
inShellTopLevel(navController, expandPlayer)
|
inShellTopLevel(navController, expandPlayer)
|
||||||
@@ -146,9 +148,7 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
|||||||
NowPlayingScreen()
|
NowPlayingScreen()
|
||||||
}
|
}
|
||||||
composable<Queue> { ComingSoon("Queue", "Lands in a later 6.x slice.") }
|
composable<Queue> { ComingSoon("Queue", "Lands in a later 6.x slice.") }
|
||||||
composable<ServerUrl> {
|
composable<ServerUrl> { ServerUrlScreen(navController = navController) }
|
||||||
ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.")
|
|
||||||
}
|
|
||||||
composable<Login> { LoginScreen(navController = navController) }
|
composable<Login> { LoginScreen(navController = navController) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user