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:
2026-05-25 12:31:22 -04:00
parent df026d8e74
commit 5697bfeedf
4 changed files with 251 additions and 12 deletions
@@ -4,10 +4,18 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.theme.MinstrelTheme
import dagger.hilt.android.AndroidEntryPoint
@@ -24,12 +32,29 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun App() {
// No bottom nav, no drawer — navigation is per-screen via the
// 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, modifier = Modifier.fillMaxSize())
private fun App(gate: AuthGateViewModel = hiltViewModel()) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination
if (resolved == null) {
BootSplash()
} else {
// No bottom nav, no drawer — navigation is per-screen via the
// 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.AdminUsersScreen
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.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
@@ -36,12 +37,13 @@ import com.fabledsword.minstrel.shared.widgets.ShellScaffold
@Composable
fun MinstrelNavGraph(
navController: NavHostController,
startDestination: Any,
modifier: Modifier = Modifier,
) {
val expandPlayer = { navController.navigate(NowPlaying) }
NavHost(
navController = navController,
startDestination = Home,
startDestination = startDestination,
modifier = modifier.fillMaxSize(),
) {
inShellTopLevel(navController, expandPlayer)
@@ -146,9 +148,7 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
NowPlayingScreen()
}
composable<Queue> { ComingSoon("Queue", "Lands in a later 6.x slice.") }
composable<ServerUrl> {
ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.")
}
composable<ServerUrl> { ServerUrlScreen(navController = navController) }
composable<Login> { LoginScreen(navController = navController) }
}