feat: Add feature to change app theme and enable pure black dark theme

Closes: #30
This commit is contained in:
Nishant Mishra
2025-09-26 22:04:01 +05:30
parent c19f4f936c
commit 77dca1bc83
7 changed files with 251 additions and 51 deletions

View File

@@ -130,7 +130,7 @@ fun SettingsScreenRoot(
alarmSound = alarmSound,
onAlarmEnabledChange = viewModel::saveAlarmEnabled,
onVibrateEnabledChange = viewModel::saveVibrateEnabled,
onBlackThemeChange = {},
onBlackThemeChange = viewModel::saveBlackTheme,
onAlarmSoundChanged = {
viewModel.saveAlarmSound(it)
Intent(context, TimerService::class.java).apply {
@@ -138,6 +138,7 @@ fun SettingsScreenRoot(
context.startService(this)
}
},
onThemeChange = viewModel::saveTheme,
modifier = modifier
)
}
@@ -157,6 +158,7 @@ private fun SettingsScreen(
onVibrateEnabledChange: (Boolean) -> Unit,
onBlackThemeChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -164,6 +166,24 @@ private fun SettingsScreen(
checkedIconColor = colorScheme.primary,
)
val themeMap: Map<String, Pair<Int, String>> = remember {
mapOf(
"auto" to Pair(
R.drawable.brightness_auto,
"System default"
),
"light" to Pair(R.drawable.light_mode, "Light"),
"dark" to Pair(R.drawable.dark_mode, "Dark")
)
}
val reverseThemeMap: Map<String, String> = remember {
mapOf(
"System default" to "auto",
"Light" to "light",
"Dark" to "dark"
)
}
val context = LocalContext.current
val ringtonePickerLauncher = rememberLauncherForActivityResult(
@@ -190,7 +210,7 @@ private fun SettingsScreen(
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri())
}
val switchItems = remember(alarmEnabled, vibrateEnabled) {
val switchItems = remember(preferencesState.blackTheme, alarmEnabled, vibrateEnabled) {
listOf(
SettingsSwitchItem(
checked = preferencesState.blackTheme,
@@ -360,33 +380,13 @@ private fun SettingsScreen(
)
}
item {
ListItem(
leadingContent = {
Icon(
painter = painterResource(
when (preferencesState.theme) {
"dark" -> R.drawable.dark_mode
"light" -> R.drawable.light_mode
else -> R.drawable.brightness_auto
}
),
contentDescription = null
)
},
headlineContent = { Text("Theme") },
supportingContent = {
Text(
when (preferencesState.theme) {
"dark" -> "Dark"
"light" -> "Light"
else -> "System default"
}
)
},
colors = listItemColors,
ThemePickerListItem(
theme = preferencesState.theme,
themeMap = themeMap,
reverseThemeMap = reverseThemeMap,
onThemeChange = onThemeChange,
modifier = Modifier
.clip(middleListItemShape)
.clickable(onClick = {})
)
}
item {
@@ -436,7 +436,7 @@ private fun SettingsScreen(
Text(
remember(alarmSound) {
RingtoneManager.getRingtone(context, alarmSound.toUri())
.getTitle(context)
?.getTitle(context) ?: ""
}
)
},
@@ -530,9 +530,9 @@ fun SettingsScreenPreview() {
TomatoTheme {
SettingsScreen(
preferencesState = PreferencesState(),
focusTimeInputFieldState = rememberTextFieldState((25 * 60 * 1000).toString()),
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()),
focusTimeInputFieldState = rememberTextFieldState((25).toString()),
shortBreakTimeInputFieldState = rememberTextFieldState((5).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15).toString()),
sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f),
alarmEnabled = true,
vibrateEnabled = true,
@@ -541,6 +541,7 @@ fun SettingsScreenPreview() {
onVibrateEnabledChange = {},
onBlackThemeChange = {},
onAlarmSoundChanged = {},
onThemeChange = {},
modifier = Modifier.fillMaxSize()
)
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ThemeDialog(
themeMap: Map<String, Pair<Int, String>>,
reverseThemeMap: Map<String, String>,
theme: String,
setShowThemeDialog: (Boolean) -> Unit,
onThemeChange: (String) -> Unit
) {
val selectedOption =
remember { mutableStateOf(themeMap[theme]!!.second) }
BasicAlertDialog(
onDismissRequest = { setShowThemeDialog(false) }
) {
Surface(
modifier = Modifier
.wrapContentWidth()
.wrapContentHeight(),
shape = shapes.extraLarge,
color = colorScheme.surfaceContainer,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = "Choose theme",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(24.dp))
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.selectableGroup()
) {
themeMap.entries.forEachIndexed { index: Int, pair: Map.Entry<String, Pair<Int, String>> ->
val text = pair.value.second
ListItem(
leadingContent = {
RadioButton(
selected = (text == selectedOption.value),
onClick = null // null recommended for accessibility with screenreaders
)
},
headlineContent = {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
)
},
colors = listItemColors,
modifier = Modifier
.clip(
when (index) {
0 -> topListItemShape
themeMap.size - 1 -> bottomListItemShape
else -> middleListItemShape
}
)
.selectable(
selected = (text == selectedOption.value),
onClick = {
selectedOption.value = text
onThemeChange(reverseThemeMap[selectedOption.value]!!)
},
role = Role.RadioButton
)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
TextButton(
shapes = ButtonDefaults.shapes(),
onClick = { setShowThemeDialog(false) },
modifier = Modifier.align(Alignment.End)
) {
Text("Ok")
}
}
}
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
@Composable
fun ThemePickerListItem(
theme: String,
themeMap: Map<String, Pair<Int, String>>,
reverseThemeMap: Map<String, String>,
onThemeChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
if (showDialog) {
ThemeDialog(
themeMap = themeMap,
reverseThemeMap = reverseThemeMap,
theme = theme,
setShowThemeDialog = { showDialog = it },
onThemeChange = onThemeChange
)
}
ListItem(
leadingContent = {
Icon(
painter = painterResource(themeMap[theme]!!.first),
contentDescription = null
)
},
headlineContent = { Text("Theme") },
supportingContent = {
Text(themeMap[theme]!!.second)
},
colors = listItemColors,
modifier = modifier
.fillMaxWidth()
.clickable { showDialog = true }
)
}

View File

@@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color
@Immutable
data class PreferencesState(
val theme: String = "system",
val theme: String = "auto",
val colorScheme: String = Color.White.toString(),
val blackTheme: Boolean = false
)

View File

@@ -65,7 +65,7 @@ class SettingsViewModel(
init {
viewModelScope.launch {
val theme = preferenceRepository.getStringPreference("theme")
?: preferenceRepository.saveStringPreference("theme", "system")
?: preferenceRepository.saveStringPreference("theme", "auto")
val colorScheme = preferenceRepository.getStringPreference("color_scheme")
?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
@@ -128,50 +128,50 @@ class SettingsViewModel(
fun saveAlarmEnabled(enabled: Boolean) {
viewModelScope.launch {
preferenceRepository.saveBooleanPreference("alarm_enabled", enabled)
timerRepository.alarmEnabled = enabled
preferenceRepository.saveBooleanPreference("alarm_enabled", enabled)
}
}
fun saveVibrateEnabled(enabled: Boolean) {
viewModelScope.launch {
preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled)
timerRepository.vibrateEnabled = enabled
preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled)
}
}
fun saveAlarmSound(uri: Uri?) {
viewModelScope.launch {
timerRepository.alarmSoundUri = uri
preferenceRepository.saveStringPreference("alarm_sound", uri.toString())
}
timerRepository.alarmSoundUri = uri
}
fun saveColorScheme(colorScheme: Color) {
viewModelScope.launch {
_preferencesState.update { currentState ->
currentState.copy(colorScheme = colorScheme.toString())
}
preferenceRepository.saveStringPreference("color_scheme", colorScheme.toString())
}
_preferencesState.update { currentState ->
currentState.copy(colorScheme = colorScheme.toString())
}
}
fun saveTheme(theme: String) {
viewModelScope.launch {
_preferencesState.update { currentState ->
currentState.copy(theme = theme)
}
preferenceRepository.saveStringPreference("theme", theme)
}
_preferencesState.update { currentState ->
currentState.copy(theme = theme)
}
}
fun saveBlackTheme(blackTheme: Boolean) {
viewModelScope.launch {
_preferencesState.update { currentState ->
currentState.copy(blackTheme = blackTheme)
}
preferenceRepository.saveBooleanPreference("black_theme", blackTheme)
}
_preferencesState.update { currentState ->
currentState.copy(blackTheme = blackTheme)
}
}
companion object {

View File

@@ -18,15 +18,17 @@ val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
object CustomColors {
var black = false
@OptIn(ExperimentalMaterial3Api::class)
val topBarColors: TopAppBarColors
@Composable get() {
return TopAppBarDefaults.topAppBarColors(
containerColor = colorScheme.surfaceContainer,
scrolledContainerColor = colorScheme.surfaceContainer
@Composable get() =
TopAppBarDefaults.topAppBarColors(
containerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface,
scrolledContainerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface
)
}
val listItemColors: ListItemColors
@Composable get() = ListItemDefaults.colors(containerColor = colorScheme.surfaceBright)
@Composable get() =
ListItemDefaults.colors(containerColor = if (!black) colorScheme.surfaceBright else colorScheme.surfaceContainer)
}

View File

@@ -1,5 +1,6 @@
package org.nsh07.pomodoro.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -10,8 +11,11 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.materialkolor.dynamiccolor.ColorSpec
import com.materialkolor.rememberDynamicColorScheme
@@ -56,6 +60,15 @@ fun TomatoTheme(
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
CustomColors.black = blackTheme && darkTheme
val dynamicColorScheme = rememberDynamicColorScheme(
seedColor = when (seedColor) {
Color.White -> colorScheme.primary