feat: Add feature to change app theme and enable pure black dark theme
Closes: #30
This commit is contained in:
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user