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 4e162506..9e5e6555 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -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) + } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt index 2345f7be..cc1ae149 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -40,12 +40,16 @@ class AuthStore @Inject constructor( private val userJsonState = MutableStateFlow(null) val userJson: StateFlow = userJsonState.asStateFlow() + private val themeModeState = MutableStateFlow(null) + val themeMode: StateFlow = 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 { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index 78090c67..819cd2e8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt index bb2e20bd..683564cd 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt @@ -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?) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt index fe24afa6..42e1cce1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt @@ -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, ) 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 index 8cb29121..a070d6ef 100644 --- 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 @@ -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()) {