diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index d9924682..4e162506 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -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) + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt new file mode 100644 index 00000000..f4bd2fa8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt @@ -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(null) + val startDestination: StateFlow = 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 + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/ServerUrlScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/ServerUrlScreen.kt new file mode 100644 index 00000000..7db7d984 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/ServerUrlScreen.kt @@ -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 = 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") } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 13aa6476..8aa5ae47 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { ComingSoon("Queue", "Lands in a later 6.x slice.") } - composable { - ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.") - } + composable { ServerUrlScreen(navController = navController) } composable { LoginScreen(navController = navController) } }