fix(android): include modified files for Phase 20 (previous commit missed them)

Previous commit (45e2248) committed only the two new theme files; the
six modified files (MainActivity, AuthStore, AppDatabase, AuthSessionDao,
AuthSessionEntity, SettingsScreen) silently didn't get staged. This
commit lands the actual integration so the theme picker works end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:44:38 -04:00
parent 45e2248970
commit 95c5067dbf
6 changed files with 117 additions and 27 deletions
@@ -8,6 +8,7 @@ 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.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -18,6 +19,7 @@ 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 com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -25,36 +27,45 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MinstrelTheme { App() }
setContent { App() }
}
}
@Composable
private fun App(
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
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 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)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
}
@@ -40,12 +40,16 @@ class AuthStore @Inject constructor(
private val userJsonState = MutableStateFlow<String?>(null)
val userJson: StateFlow<String?> = userJsonState.asStateFlow()
private val themeModeState = MutableStateFlow<String?>(null)
val themeMode: StateFlow<String?> = themeModeState.asStateFlow()
init {
scope.launch {
dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode
}
}
}
@@ -65,6 +69,11 @@ class AuthStore @Inject constructor(
scope.launch { persistUserJson(value) }
}
fun setThemeMode(value: String?) {
themeModeState.value = value
scope.launch { persistThemeMode(value) }
}
private suspend fun persistCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -89,11 +98,20 @@ class AuthStore @Inject constructor(
}
}
private suspend fun persistThemeMode(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(themeMode = value))
} else {
dao.setThemeMode(value)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
sessionCookie = sessionCookieState.value,
baseUrl = baseUrlState.value,
userJson = userJsonState.value,
themeMode = themeModeState.value,
)
companion object {
@@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHomeIndexEntity::class,
AuthSessionEntity::class,
],
version = 2,
version = 3,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -30,4 +30,8 @@ interface AuthSessionDao {
/** Partial update: change only the serialized user identity JSON. */
@Query("UPDATE auth_session SET userJson = :userJson WHERE id = 0")
suspend fun setUserJson(userJson: String?)
/** Partial update: change only the theme override ("light"/"dark"/null=system). */
@Query("UPDATE auth_session SET themeMode = :themeMode WHERE id = 0")
suspend fun setThemeMode(themeMode: String?)
}
@@ -5,7 +5,8 @@ import androidx.room.PrimaryKey
/**
* Single-row table holding the user's session cookie, configured
* server base URL, and the serialized current user identity.
* server base URL, the serialized current user identity, and the
* theme-override preference.
*
* `id = 0` is the only valid row. `sessionCookie` is null when the
* user is logged out (or has never logged in). `baseUrl` always has
@@ -13,6 +14,12 @@ import androidx.room.PrimaryKey
* configures one in Settings. `userJson` is the JSON-encoded
* UserRef captured at sign-in so the username + isAdmin survive
* a cold restart (the cookie alone doesn't carry identity).
* `themeMode` is "system" / "light" / "dark" (or null = system).
*
* The table is the de-facto single-row app-prefs row at this point,
* not strictly auth-only. Kept under the auth_session name to avoid
* a schema-rename migration; a future cleanup could rename to
* `app_preferences`.
*/
@Entity(tableName = "auth_session")
data class AuthSessionEntity(
@@ -20,4 +27,5 @@ data class AuthSessionEntity(
val sessionCookie: String? = null,
val baseUrl: String,
val userJson: String? = null,
val themeMode: String? = null,
)
@@ -18,6 +18,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -35,14 +38,18 @@ 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
import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
navController: NavHostController,
viewModel: SettingsViewModel = hiltViewModel(),
themeVm: ThemePreferenceViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val themeMode by themeVm.themeMode.collectAsStateWithLifecycle()
LaunchedEffect(state.signedOut) {
if (state.signedOut) {
@@ -75,6 +82,7 @@ fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AccountCard(username = state.username, serverUrl = state.serverUrl)
AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode)
AboutCard()
SignOutButton(isSigningOut = state.isSigningOut, onSignOut = viewModel::signOut)
}
@@ -115,6 +123,47 @@ private fun AccountCard(username: String, serverUrl: String) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
val options = ThemeMode.entries
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "System follows your device setting.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == themeMode,
onClick = { onPick(mode) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size,
),
) { Text(displayName(mode)) }
}
}
}
}
}
private fun displayName(mode: ThemeMode): String = when (mode) {
ThemeMode.SYSTEM -> "System"
ThemeMode.LIGHT -> "Light"
ThemeMode.DARK -> "Dark"
}
@Composable
private fun AboutCard() {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {