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.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) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user