feat(android): Phase 11 Commit C — Settings screen + sign-out

Closes the auth loop. Settings shows the signed-in username and
server URL, and the sign-out button drops the cookie + clears
currentUser + best-effort POSTs /api/auth/logout.

New:
  - settings/ui/SettingsViewModel.kt — VM + SettingsState (server URL,
    username, signing-out spinner, signed-out latch).
  - settings/ui/SettingsScreen.kt — three cards: Account (signed-in
    username + server URL), About (Minstrel + version from
    BuildConfig.VERSION_NAME + VERSION_CODE), Sign out (error-tinted
    button that spins while in-flight). On signed-out, navigates to
    ServerUrl with `popUpTo(0) { inclusive = true }` so the back
    stack is empty.

Modified:
  - nav/MinstrelNavGraph.kt — Settings route renders the real screen.

Closes Phase 11 modulo cross-restart user-identity persistence —
the cookie survives but `currentUser` is in-memory only, so a fresh
launch with an existing cookie leaves the username blank until the
next sign-in. That's the only known gap; queued as a small
AuthSessionEntity schema add when it surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:42:22 -04:00
parent 5697bfeedf
commit cb4b411ab6
3 changed files with 213 additions and 1 deletions
@@ -23,6 +23,7 @@ import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
import com.fabledsword.minstrel.requests.ui.RequestsScreen
import com.fabledsword.minstrel.settings.ui.SettingsScreen
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
@@ -83,7 +84,7 @@ private fun NavGraphBuilder.inShellTopLevel(
}
composable<Settings> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Settings", "Lands in Phase 11.")
SettingsScreen(navController = navController)
}
}
composable<Admin> {
@@ -0,0 +1,163 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.LogOut
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.nav.ServerUrl
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
navController: NavHostController,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.signedOut) {
if (state.signedOut) {
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
viewModel.consumeSignedOut()
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Settings") },
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = SettingsRoute::class.qualifiedName,
)
},
)
},
) { inner ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(inner)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AccountCard(username = state.username, serverUrl = state.serverUrl)
AboutCard()
SignOutButton(isSigningOut = state.isSigningOut, onSignOut = viewModel::signOut)
}
}
}
@Composable
private fun AccountCard(username: String, serverUrl: String) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Account",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = if (username.isEmpty()) "Signed in" else "Signed in as $username",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Server",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = serverUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun AboutCard() {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) {
Button(
onClick = onSignOut,
enabled = !isSigningOut,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
if (isSigningOut) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
strokeWidth = 2.dp,
)
} else {
Box(modifier = Modifier.padding(end = 4.dp)) {
Icon(Lucide.LogOut, contentDescription = null)
}
Text("Sign out")
}
}
}
@@ -0,0 +1,48 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.auth.AuthStore
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
data class SettingsState(
val serverUrl: String = "",
val username: String = "",
val isSigningOut: Boolean = false,
val signedOut: Boolean = false,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val authController: AuthController,
authStore: AuthStore,
) : ViewModel() {
private val internal = MutableStateFlow(
SettingsState(
serverUrl = authStore.baseUrl.value,
username = authController.currentUser.value?.username.orEmpty(),
),
)
val state: StateFlow<SettingsState> = internal.asStateFlow()
fun signOut() {
if (internal.value.isSigningOut) return
viewModelScope.launch {
internal.update { it.copy(isSigningOut = true) }
authController.signOut()
internal.update { it.copy(isSigningOut = false, signedOut = true) }
}
}
fun consumeSignedOut() {
internal.update { it.copy(signedOut = false) }
}
}