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:
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
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.auth.ui.AuthGateViewModel
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -25,36 +27,45 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent { App() }
|
||||||
MinstrelTheme { 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
|
@Composable
|
||||||
private fun BootSplash() {
|
private fun BootSplash() {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Surface(
|
||||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
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)
|
private val userJsonState = MutableStateFlow<String?>(null)
|
||||||
val userJson: StateFlow<String?> = userJsonState.asStateFlow()
|
val userJson: StateFlow<String?> = userJsonState.asStateFlow()
|
||||||
|
|
||||||
|
private val themeModeState = MutableStateFlow<String?>(null)
|
||||||
|
val themeMode: StateFlow<String?> = themeModeState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
dao.observe().collect { row ->
|
dao.observe().collect { row ->
|
||||||
sessionCookieState.value = row?.sessionCookie
|
sessionCookieState.value = row?.sessionCookie
|
||||||
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
||||||
userJsonState.value = row?.userJson
|
userJsonState.value = row?.userJson
|
||||||
|
themeModeState.value = row?.themeMode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,11 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistUserJson(value) }
|
scope.launch { persistUserJson(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setThemeMode(value: String?) {
|
||||||
|
themeModeState.value = value
|
||||||
|
scope.launch { persistThemeMode(value) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
private suspend fun persistCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
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(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
sessionCookie = sessionCookieState.value,
|
sessionCookie = sessionCookieState.value,
|
||||||
baseUrl = baseUrlState.value,
|
baseUrl = baseUrlState.value,
|
||||||
userJson = userJsonState.value,
|
userJson = userJsonState.value,
|
||||||
|
themeMode = themeModeState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHomeIndexEntity::class,
|
CachedHomeIndexEntity::class,
|
||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
],
|
],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
|
|||||||
+4
@@ -30,4 +30,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized user identity JSON. */
|
/** Partial update: change only the serialized user identity JSON. */
|
||||||
@Query("UPDATE auth_session SET userJson = :userJson WHERE id = 0")
|
@Query("UPDATE auth_session SET userJson = :userJson WHERE id = 0")
|
||||||
suspend fun setUserJson(userJson: String?)
|
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?)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -5,7 +5,8 @@ import androidx.room.PrimaryKey
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-row table holding the user's session cookie, configured
|
* 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
|
* `id = 0` is the only valid row. `sessionCookie` is null when the
|
||||||
* user is logged out (or has never logged in). `baseUrl` always has
|
* 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
|
* configures one in Settings. `userJson` is the JSON-encoded
|
||||||
* UserRef captured at sign-in so the username + isAdmin survive
|
* UserRef captured at sign-in so the username + isAdmin survive
|
||||||
* a cold restart (the cookie alone doesn't carry identity).
|
* 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")
|
@Entity(tableName = "auth_session")
|
||||||
data class AuthSessionEntity(
|
data class AuthSessionEntity(
|
||||||
@@ -20,4 +27,5 @@ data class AuthSessionEntity(
|
|||||||
val sessionCookie: String? = null,
|
val sessionCookie: String? = null,
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
val userJson: String? = null,
|
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.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
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.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
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.Settings as SettingsRoute
|
||||||
import com.fabledsword.minstrel.nav.ServerUrl
|
import com.fabledsword.minstrel.nav.ServerUrl
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
|
import com.fabledsword.minstrel.theme.ThemeMode
|
||||||
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val themeMode by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(state.signedOut) {
|
LaunchedEffect(state.signedOut) {
|
||||||
if (state.signedOut) {
|
if (state.signedOut) {
|
||||||
@@ -75,6 +82,7 @@ fun SettingsScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
AccountCard(username = state.username, serverUrl = state.serverUrl)
|
AccountCard(username = state.username, serverUrl = state.serverUrl)
|
||||||
|
AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode)
|
||||||
AboutCard()
|
AboutCard()
|
||||||
SignOutButton(isSigningOut = state.isSigningOut, onSignOut = viewModel::signOut)
|
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
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard() {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user