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, alarmSound = alarmSound,
onAlarmEnabledChange = viewModel::saveAlarmEnabled, onAlarmEnabledChange = viewModel::saveAlarmEnabled,
onVibrateEnabledChange = viewModel::saveVibrateEnabled, onVibrateEnabledChange = viewModel::saveVibrateEnabled,
onBlackThemeChange = {}, onBlackThemeChange = viewModel::saveBlackTheme,
onAlarmSoundChanged = { onAlarmSoundChanged = {
viewModel.saveAlarmSound(it) viewModel.saveAlarmSound(it)
Intent(context, TimerService::class.java).apply { Intent(context, TimerService::class.java).apply {
@@ -138,6 +138,7 @@ fun SettingsScreenRoot(
context.startService(this) context.startService(this)
} }
}, },
onThemeChange = viewModel::saveTheme,
modifier = modifier modifier = modifier
) )
} }
@@ -157,6 +158,7 @@ private fun SettingsScreen(
onVibrateEnabledChange: (Boolean) -> Unit, onVibrateEnabledChange: (Boolean) -> Unit,
onBlackThemeChange: (Boolean) -> Unit, onBlackThemeChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit, onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -164,6 +166,24 @@ private fun SettingsScreen(
checkedIconColor = colorScheme.primary, 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 context = LocalContext.current
val ringtonePickerLauncher = rememberLauncherForActivityResult( val ringtonePickerLauncher = rememberLauncherForActivityResult(
@@ -190,7 +210,7 @@ private fun SettingsScreen(
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri()) putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri())
} }
val switchItems = remember(alarmEnabled, vibrateEnabled) { val switchItems = remember(preferencesState.blackTheme, alarmEnabled, vibrateEnabled) {
listOf( listOf(
SettingsSwitchItem( SettingsSwitchItem(
checked = preferencesState.blackTheme, checked = preferencesState.blackTheme,
@@ -360,33 +380,13 @@ private fun SettingsScreen(
) )
} }
item { item {
ListItem( ThemePickerListItem(
leadingContent = { theme = preferencesState.theme,
Icon( themeMap = themeMap,
painter = painterResource( reverseThemeMap = reverseThemeMap,
when (preferencesState.theme) { onThemeChange = onThemeChange,
"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,
modifier = Modifier modifier = Modifier
.clip(middleListItemShape) .clip(middleListItemShape)
.clickable(onClick = {})
) )
} }
item { item {
@@ -436,7 +436,7 @@ private fun SettingsScreen(
Text( Text(
remember(alarmSound) { remember(alarmSound) {
RingtoneManager.getRingtone(context, alarmSound.toUri()) RingtoneManager.getRingtone(context, alarmSound.toUri())
.getTitle(context) ?.getTitle(context) ?: ""
} }
) )
}, },
@@ -530,9 +530,9 @@ fun SettingsScreenPreview() {
TomatoTheme { TomatoTheme {
SettingsScreen( SettingsScreen(
preferencesState = PreferencesState(), preferencesState = PreferencesState(),
focusTimeInputFieldState = rememberTextFieldState((25 * 60 * 1000).toString()), focusTimeInputFieldState = rememberTextFieldState((25).toString()),
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()), shortBreakTimeInputFieldState = rememberTextFieldState((5).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()), longBreakTimeInputFieldState = rememberTextFieldState((15).toString()),
sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f), sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f),
alarmEnabled = true, alarmEnabled = true,
vibrateEnabled = true, vibrateEnabled = true,
@@ -541,6 +541,7 @@ fun SettingsScreenPreview() {
onVibrateEnabledChange = {}, onVibrateEnabledChange = {},
onBlackThemeChange = {}, onBlackThemeChange = {},
onAlarmSoundChanged = {}, onAlarmSoundChanged = {},
onThemeChange = {},
modifier = Modifier.fillMaxSize() 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 @Immutable
data class PreferencesState( data class PreferencesState(
val theme: String = "system", val theme: String = "auto",
val colorScheme: String = Color.White.toString(), val colorScheme: String = Color.White.toString(),
val blackTheme: Boolean = false val blackTheme: Boolean = false
) )

View File

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

View File

@@ -18,15 +18,17 @@ val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)
object CustomColors { object CustomColors {
var black = false
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
val topBarColors: TopAppBarColors val topBarColors: TopAppBarColors
@Composable get() { @Composable get() =
return TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = colorScheme.surfaceContainer, containerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface,
scrolledContainerColor = colorScheme.surfaceContainer scrolledContainerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface
) )
}
val listItemColors: ListItemColors 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 package org.nsh07.pomodoro.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -10,8 +11,11 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext 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.dynamiccolor.ColorSpec
import com.materialkolor.rememberDynamicColorScheme import com.materialkolor.rememberDynamicColorScheme
@@ -56,6 +60,15 @@ fun TomatoTheme(
else -> LightColorScheme 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( val dynamicColorScheme = rememberDynamicColorScheme(
seedColor = when (seedColor) { seedColor = when (seedColor) {
Color.White -> colorScheme.primary Color.White -> colorScheme.primary