From cb4b411ab64bcfbc099cbff0cc9ab7cfd7424990 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 12:42:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2011=20Commit=20C=20?= =?UTF-8?q?=E2=80=94=20Settings=20screen=20+=20sign-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/nav/MinstrelNavGraph.kt | 3 +- .../minstrel/settings/ui/SettingsScreen.kt | 163 ++++++++++++++++++ .../minstrel/settings/ui/SettingsViewModel.kt | 48 ++++++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt 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 8aa5ae47..44e866d0 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 @@ -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 { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Settings", "Lands in Phase 11.") + SettingsScreen(navController = navController) } } composable { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt new file mode 100644 index 00000000..8cb29121 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -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") + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt new file mode 100644 index 00000000..27d19853 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt @@ -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 = 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) } + } +}