feat: dark mode support — ThemeMode setting, dynamic colors, status bar sync (batch v0.2.4)

- Add ThemeMode enum (DEVICE/LIGHT/DARK) with DataStore persistence
- SettingsScreen: appearance section with radio buttons for Device/Light/Dark
- SettingsViewModel: observe and persist theme mode
- MainActivity: read theme setting, apply dark theme, sync status bar icons
- Replace hardcoded Color.White references with MaterialTheme.colorScheme.surface across auth, onboarding, settings, and theme
- Convert AuthVisuals, SettingsVisuals, CloserPalette brushes to @Composable getters using dynamic scheme colors
- Add values-night/themes.xml for dark theme manifest entry
- Add ThemeModeTest unit test for fromStorageValue parsing
This commit is contained in:
null 2026-06-19 19:00:37 -05:00
parent c878a9be1f
commit 7256a71bdf
15 changed files with 217 additions and 48 deletions

View File

@ -4,19 +4,44 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import app.closer.core.navigation.AppNavigation
import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.ThemeMode
import app.closer.ui.theme.CloserTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var settingsRepository: SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CloserTheme {
val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
val systemInDarkTheme = isSystemInDarkTheme()
val useDarkTheme = when (settings.themeMode) {
ThemeMode.DEVICE -> systemInDarkTheme
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
CloserTheme(darkTheme = useDarkTheme) {
SideEffect {
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = !useDarkTheme
isAppearanceLightNavigationBars = !useDarkTheme
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background

View File

@ -5,9 +5,11 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import app.closer.core.notifications.QuietHours
import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.ThemeMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ -27,6 +29,7 @@ class SettingsDataStore @Inject constructor(
private val QUIET_HOURS_END_HOUR = intPreferencesKey("quiet_hours_end_hour")
private val QUIET_HOURS_END_MINUTE = intPreferencesKey("quiet_hours_end_minute")
private val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
private val THEME_MODE = stringPreferencesKey("theme_mode")
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
AppSettings(
@ -41,7 +44,8 @@ class SettingsDataStore @Inject constructor(
endHour = prefs[QUIET_HOURS_END_HOUR] ?: 8,
endMinute = prefs[QUIET_HOURS_END_MINUTE] ?: 0
),
onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false
onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false,
themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE])
)
}
@ -68,4 +72,7 @@ class SettingsDataStore @Inject constructor(
override suspend fun setOnboardingComplete(complete: Boolean) =
dataStore.edit { it[ONBOARDING_COMPLETE] = complete }.let {}
override suspend fun setThemeMode(mode: ThemeMode) =
dataStore.edit { it[THEME_MODE] = mode.name }.let {}
}

View File

@ -3,13 +3,25 @@ package app.closer.domain.repository
import app.closer.core.notifications.QuietHours
import kotlinx.coroutines.flow.Flow
enum class ThemeMode {
DEVICE,
LIGHT,
DARK;
companion object {
fun fromStorageValue(value: String?): ThemeMode =
entries.firstOrNull { it.name == value } ?: DEVICE
}
}
data class AppSettings(
val dailyReminderEnabled: Boolean = true,
val partnerAnsweredEnabled: Boolean = true,
val streakReminderEnabled: Boolean = false,
val quietHoursEnabled: Boolean = false,
val quietHours: QuietHours = QuietHours(),
val onboardingComplete: Boolean = false
val onboardingComplete: Boolean = false,
val themeMode: ThemeMode = ThemeMode.DEVICE
)
interface SettingsRepository {
@ -20,4 +32,5 @@ interface SettingsRepository {
suspend fun setQuietHours(enabled: Boolean)
suspend fun setQuietHours(quietHours: QuietHours)
suspend fun setOnboardingComplete(complete: Boolean)
suspend fun setThemeMode(mode: ThemeMode)
}

View File

@ -25,25 +25,29 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.BackgroundColor
import app.closer.ui.theme.OnBackgroundColor
import app.closer.ui.theme.OnPrimaryColor
import app.closer.ui.theme.OnSurfaceVariantColor
import app.closer.ui.theme.PrimaryColor
internal val AuthBackgroundBrush: Brush
@Composable
get() = Brush.linearGradient(
colors = listOf(BackgroundColor, CloserPalette.BackgroundWash, CloserPalette.PinkMist),
colors = listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.secondaryContainer
),
start = Offset.Zero,
end = Offset.Infinite
)
internal val AuthInk = OnBackgroundColor
internal val AuthMuted = OnSurfaceVariantColor
internal val AuthPrimary = PrimaryColor
internal val AuthPrimaryDeep = CloserPalette.PurpleDeep
internal val AuthOnPrimary = OnPrimaryColor
internal val AuthInk: Color
@Composable get() = MaterialTheme.colorScheme.onBackground
internal val AuthMuted: Color
@Composable get() = MaterialTheme.colorScheme.onSurfaceVariant
internal val AuthPrimary: Color
@Composable get() = MaterialTheme.colorScheme.primary
internal val AuthPrimaryDeep: Color
@Composable get() = MaterialTheme.colorScheme.onPrimaryContainer
internal val AuthOnPrimary: Color
@Composable get() = MaterialTheme.colorScheme.onPrimary
@Composable
internal fun GoogleSignInButton(
@ -91,7 +95,7 @@ internal fun authTextFieldColors() = OutlinedTextFieldDefaults.colors(
unfocusedLabelColor = AuthMuted,
cursorColor = AuthPrimaryDeep,
focusedContainerColor = closerCardColor(alpha = 0.92f),
unfocusedContainerColor = Color.White.copy(alpha = 0.78f),
unfocusedContainerColor = closerCardColor(alpha = 0.78f),
focusedTextColor = AuthInk,
unfocusedTextColor = AuthInk,
focusedTrailingIconColor = AuthPrimaryDeep,

View File

@ -216,7 +216,7 @@ fun LoginScreen(
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.White.copy(alpha = 0.62f),
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.62f),
contentColor = AuthPrimaryDeep
),
border = BorderStroke(1.dp, AuthPrimaryDeep.copy(alpha = 0.28f))

View File

@ -318,7 +318,7 @@ private fun SexStep(
@Composable
private fun SexOption(label: String, selected: Boolean, onClick: () -> Unit) {
val background = if (selected) AuthPrimary else Color.White.copy(alpha = 0.78f)
val background = if (selected) AuthPrimary else MaterialTheme.colorScheme.surface.copy(alpha = 0.78f)
val contentColor = if (selected) AuthOnPrimary else AuthInk
val borderColor = if (selected) AuthPrimary else AuthMuted.copy(alpha = 0.24f)
@ -370,7 +370,7 @@ private fun PhotoStep(
modifier = Modifier
.size(140.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.78f))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.78f))
.border(2.dp, AuthPrimary.copy(alpha = 0.3f), CircleShape),
contentAlignment = Alignment.Center
) {
@ -399,7 +399,7 @@ private fun PhotoStep(
onClick = onGallery,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.White.copy(alpha = 0.78f),
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
contentColor = AuthInk
)
) {
@ -413,7 +413,7 @@ private fun PhotoStep(
onClick = onCamera,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.White.copy(alpha = 0.78f),
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
contentColor = AuthInk
)
) {

View File

@ -300,7 +300,7 @@ private fun AnswerPreviewVisual() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(

View File

@ -180,7 +180,7 @@ fun EditProfileContent(
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.background(Color.White)
.background(SettingsCard)
.border(2.dp, SettingsPrimary.copy(alpha = 0.3f), CircleShape)
.clickable {
galleryLauncher.launch(
@ -233,7 +233,7 @@ fun EditProfileContent(
},
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.White,
containerColor = SettingsCard,
contentColor = SettingsInk
)
) {
@ -249,7 +249,7 @@ fun EditProfileContent(
},
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.White,
containerColor = SettingsCard,
contentColor = SettingsInk
)
) {
@ -265,7 +265,7 @@ fun EditProfileContent(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.background(SettingsCard)
.padding(16.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
@ -370,7 +370,7 @@ private fun SexEditOption(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val background = if (selected) SettingsPrimary else Color.White
val background = if (selected) SettingsPrimary else SettingsCard
val contentColor = if (selected) SettingsOnPrimary else SettingsInk
val borderColor = if (selected) SettingsPrimary else SettingsMuted.copy(alpha = 0.24f)

View File

@ -39,6 +39,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -59,6 +60,7 @@ import android.content.Context
import androidx.compose.ui.platform.LocalContext
import app.closer.core.navigation.AppRoute
import app.closer.core.navigation.ExternalLinks
import app.closer.domain.repository.ThemeMode
import app.closer.ui.settings.SettingsDanger
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
@ -324,6 +326,44 @@ fun SettingsScreen(
Spacer(Modifier.height(4.dp))
Text(
text = "Appearance",
style = MaterialTheme.typography.labelLarge,
color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
) {
Column {
ThemeModeRow(
label = "Device",
description = "Match your device appearance",
selected = state.themeMode == ThemeMode.DEVICE,
onClick = { viewModel.setThemeMode(ThemeMode.DEVICE) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
ThemeModeRow(
label = "Light",
description = "Always use light mode",
selected = state.themeMode == ThemeMode.LIGHT,
onClick = { viewModel.setThemeMode(ThemeMode.LIGHT) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
ThemeModeRow(
label = "Dark",
description = "Always use dark mode",
selected = state.themeMode == ThemeMode.DARK,
onClick = { viewModel.setThemeMode(ThemeMode.DARK) }
)
}
}
Spacer(Modifier.height(4.dp))
// General section
Card(
modifier = Modifier.fillMaxWidth(),
@ -429,6 +469,37 @@ fun SettingsScreen(
}
}
@Composable
private fun ThemeModeRow(
label: String,
description: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
RadioButton(selected = selected, onClick = onClick)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = SettingsInk
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
}
}
}
@Composable
private fun SettingsRow(
icon: ImageVector,

View File

@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.ThemeMode
import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -22,6 +24,7 @@ data class SettingsUiState(
val partnerName: String? = null,
val isPaired: Boolean = false,
val isSigningOut: Boolean = false,
val themeMode: ThemeMode = ThemeMode.DEVICE,
val navigateTo: String? = null
)
@ -29,7 +32,8 @@ data class SettingsUiState(
class SettingsViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
private val coupleRepository: CoupleRepository
private val coupleRepository: CoupleRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -37,6 +41,20 @@ class SettingsViewModel @Inject constructor(
init {
loadSettings()
observeThemeMode()
}
private fun observeThemeMode() {
viewModelScope.launch {
settingsRepository.settings.collect { settings ->
_uiState.update { it.copy(themeMode = settings.themeMode) }
}
}
}
fun setThemeMode(mode: ThemeMode) {
_uiState.update { it.copy(themeMode = mode) }
viewModelScope.launch { settingsRepository.setThemeMode(mode) }
}
private fun loadSettings() {

View File

@ -1,32 +1,39 @@
package app.closer.ui.settings
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import app.closer.ui.theme.BackgroundColor
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.OnBackgroundColor
import app.closer.ui.theme.OnPrimaryColor
import app.closer.ui.theme.OnSurfaceVariantColor
import app.closer.ui.theme.PrimaryColor
internal val SettingsBackgroundBrush: Brush
@Composable
get() = Brush.linearGradient(
colors = listOf(BackgroundColor, CloserPalette.BackgroundWash, CloserPalette.PinkMist),
colors = listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.secondaryContainer
),
start = Offset.Zero,
end = Offset.Infinite
)
internal val SettingsInk = OnBackgroundColor
internal val SettingsMuted = OnSurfaceVariantColor
internal val SettingsPrimary = PrimaryColor
internal val SettingsPrimaryDeep = CloserPalette.PurpleDeep
internal val SettingsOnPrimary = OnPrimaryColor
internal val SettingsCard = Color.White
internal val SettingsSoft = CloserPalette.PurpleSoft
internal val SettingsDanger = CloserPalette.Danger
internal val SettingsInk: Color
@Composable get() = MaterialTheme.colorScheme.onBackground
internal val SettingsMuted: Color
@Composable get() = MaterialTheme.colorScheme.onSurfaceVariant
internal val SettingsPrimary: Color
@Composable get() = MaterialTheme.colorScheme.primary
internal val SettingsPrimaryDeep: Color
@Composable get() = MaterialTheme.colorScheme.onPrimaryContainer
internal val SettingsOnPrimary: Color
@Composable get() = MaterialTheme.colorScheme.onPrimary
internal val SettingsCard: Color
@Composable get() = MaterialTheme.colorScheme.surface
internal val SettingsSoft: Color
@Composable get() = MaterialTheme.colorScheme.primaryContainer
internal val SettingsDanger: Color
@Composable get() = MaterialTheme.colorScheme.error
@Composable
internal fun settingsSwitchColors() = SwitchDefaults.colors(

View File

@ -30,8 +30,8 @@ fun closerBackgroundBrush(): Brush =
Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.background,
CloserPalette.BackgroundWash,
CloserPalette.PinkMist
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.secondaryContainer
),
start = Offset.Zero,
end = Offset.Infinite
@ -69,7 +69,7 @@ fun closerPlayCardBrush(): Brush =
colors = listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.primaryContainer,
CloserPalette.PinkSoft
MaterialTheme.colorScheme.secondaryContainer
),
start = Offset.Zero,
end = Offset.Infinite

View File

@ -4,12 +4,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@Composable
fun CloserTheme(
darkTheme: Boolean = false,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) darkColors else lightColors

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Closer" parent="android:Theme.Material.NoActionBar" />
</resources>

View File

@ -0,0 +1,19 @@
package app.closer.domain.repository
import org.junit.Assert.assertEquals
import org.junit.Test
class ThemeModeTest {
@Test
fun `missing or invalid stored values default to device`() {
assertEquals(ThemeMode.DEVICE, ThemeMode.fromStorageValue(null))
assertEquals(ThemeMode.DEVICE, ThemeMode.fromStorageValue("UNKNOWN"))
}
@Test
fun `stored values restore each explicit theme mode`() {
ThemeMode.entries.forEach { mode ->
assertEquals(mode, ThemeMode.fromStorageValue(mode.name))
}
}
}