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:
@@ -23,6 +23,7 @@ import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
|||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
||||||
import com.fabledsword.minstrel.requests.ui.RequestsScreen
|
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.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ private fun NavGraphBuilder.inShellTopLevel(
|
|||||||
}
|
}
|
||||||
composable<Settings> {
|
composable<Settings> {
|
||||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
ComingSoon("Settings", "Lands in Phase 11.")
|
SettingsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<Admin> {
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user