feat: Add a system for changing app theme

Changes are not user-facing yet. #30
This commit is contained in:
Nishant Mishra
2025-09-20 08:34:39 +05:30
parent 613fc3a45a
commit c35d25c97c
7 changed files with 114 additions and 21 deletions

View File

@@ -5,19 +5,23 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.nsh07.pomodoro.ui.AppScreen import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.NavItem import org.nsh07.pomodoro.ui.NavItem
import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.ui.Screen
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
import org.nsh07.pomodoro.utils.toColor
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory }) private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory })
private val statsViewModel: StatsViewModel by viewModels(factoryProducer = { StatsViewModel.Factory }) private val settingsViewModel: SettingsViewModel by viewModels(factoryProducer = { SettingsViewModel.Factory })
private val appContainer by lazy { private val appContainer by lazy {
(application as TomatoApplication).container (application as TomatoApplication).container
@@ -27,14 +31,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TomatoTheme { val preferencesState by settingsViewModel.preferencesState.collectAsStateWithLifecycle()
val darkTheme = when (preferencesState.theme) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
val seed = preferencesState.colorScheme.toColor()
TomatoTheme(
darkTheme = darkTheme,
seedColor = seed,
blackTheme = preferencesState.blackTheme
) {
val colorScheme = colorScheme val colorScheme = colorScheme
LaunchedEffect(colorScheme) { LaunchedEffect(colorScheme) {
appContainer.appTimerRepository.colorScheme = colorScheme appContainer.appTimerRepository.colorScheme = colorScheme
} }
timerViewModel.setCompositionLocals(colorScheme) AppScreen(timerViewModel = timerViewModel)
AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel)
} }
} }
} }

View File

@@ -46,7 +46,6 @@ import org.nsh07.pomodoro.MainActivity.Companion.screens
import org.nsh07.pomodoro.service.TimerService import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
import org.nsh07.pomodoro.ui.timerScreen.AlarmDialog import org.nsh07.pomodoro.ui.timerScreen.AlarmDialog
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
@@ -56,8 +55,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@Composable @Composable
fun AppScreen( fun AppScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory), timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory)
statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -194,7 +192,6 @@ fun AppScreen(
entry<Screen.Stats> { entry<Screen.Stats> {
StatsScreenRoot( StatsScreenRoot(
contentPadding = contentPadding, contentPadding = contentPadding,
viewModel = statsViewModel,
modifier = modifier.padding( modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection), start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection), end = contentPadding.calculateEndPadding(layoutDirection),

View File

@@ -0,0 +1,18 @@
/*
* 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.viewModel
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
data class PreferencesState(
val theme: String = "system",
val colorScheme: String = Color.White.toString(),
val blackTheme: Boolean = false
)

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderState import androidx.compose.material3.SliderState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -20,8 +21,11 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository import org.nsh07.pomodoro.data.AppPreferenceRepository
@@ -32,6 +36,9 @@ class SettingsViewModel(
private val preferenceRepository: AppPreferenceRepository, private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository private val timerRepository: TimerRepository
) : ViewModel() { ) : ViewModel() {
private val _preferencesState = MutableStateFlow(PreferencesState())
val preferencesState = _preferencesState.asStateFlow()
val focusTimeTextFieldState = val focusTimeTextFieldState =
TextFieldState((timerRepository.focusTime / 60000).toString()) TextFieldState((timerRepository.focusTime / 60000).toString())
val shortBreakTimeTextFieldState = val shortBreakTimeTextFieldState =
@@ -56,6 +63,22 @@ class SettingsViewModel(
preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged() preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged()
init { init {
viewModelScope.launch {
val theme = preferenceRepository.getStringPreference("theme")
?: preferenceRepository.saveStringPreference("theme", "system")
val colorScheme = preferenceRepository.getStringPreference("color_scheme")
?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
?: preferenceRepository.saveBooleanPreference("black_theme", false)
_preferencesState.update { currentState ->
currentState.copy(
theme = theme,
colorScheme = colorScheme,
blackTheme = blackTheme
)
}
}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { focusTimeTextFieldState.text } snapshotFlow { focusTimeTextFieldState.text }
.debounce(500) .debounce(500)

View File

@@ -2,13 +2,18 @@ package org.nsh07.pomodoro.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme 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.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.materialkolor.dynamiccolor.ColorSpec
import com.materialkolor.rememberDynamicColorScheme
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Purple80, primary = Purple80,
@@ -32,11 +37,13 @@ private val LightColorScheme = lightColorScheme(
*/ */
) )
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun TomatoTheme( fun TomatoTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ seedColor: Color = Color.White,
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
blackTheme: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
@@ -49,9 +56,24 @@ fun TomatoTheme(
else -> LightColorScheme else -> LightColorScheme
} }
MaterialTheme( val dynamicColorScheme = rememberDynamicColorScheme(
colorScheme = colorScheme, seedColor = when (seedColor) {
Color.White -> colorScheme.primary
else -> seedColor
},
isDark = darkTheme,
specVersion = if (blackTheme && darkTheme) ColorSpec.SpecVersion.SPEC_2021 else ColorSpec.SpecVersion.SPEC_2025,
isAmoled = blackTheme && darkTheme
)
val scheme =
if (seedColor == Color.White && !(blackTheme && darkTheme)) colorScheme
else dynamicColorScheme
MaterialExpressiveTheme(
colorScheme = scheme,
typography = Typography, typography = Typography,
motionScheme = MotionScheme.expressive(),
content = content content = content
) )
} }

View File

@@ -9,7 +9,6 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.app.Application import android.app.Application
import android.provider.Settings import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@@ -52,8 +51,6 @@ class TimerViewModel(
private var pauseTime = 0L private var pauseTime = 0L
private var pauseDuration = 0L private var pauseDuration = 0L
private lateinit var cs: ColorScheme
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime = timerRepository.focusTime =
@@ -116,10 +113,6 @@ class TimerViewModel(
} }
} }
fun setCompositionLocals(colorScheme: ColorScheme) {
cs = colorScheme
}
private fun resetTimer() { private fun resetTimer() {
viewModelScope.launch { viewModelScope.launch {
saveTimeToDb() saveTimeToDb()

View File

@@ -7,6 +7,7 @@
package org.nsh07.pomodoro.utils package org.nsh07.pomodoro.utils
import androidx.compose.ui.graphics.Color
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -36,4 +37,26 @@ fun millisecondsToHoursMinutes(t: Long): String {
"%dh %dm", TimeUnit.MILLISECONDS.toHours(t), "%dh %dm", TimeUnit.MILLISECONDS.toHours(t),
TimeUnit.MILLISECONDS.toMinutes(t) % TimeUnit.HOURS.toMinutes(1) TimeUnit.MILLISECONDS.toMinutes(t) % TimeUnit.HOURS.toMinutes(1)
) )
} }
/**
* Extension function for [String] to convert it to a [androidx.compose.ui.graphics.Color]
*
* The base string must be of the format produced by [androidx.compose.ui.graphics.Color.toString],
* i.e, the color black with 100% opacity in sRGB would be represented by:
*
* Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1)
*/
fun String.toColor(): Color {
// Sample string: Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1)
val comma1 = this.indexOf(',')
val comma2 = this.indexOf(',', comma1 + 1)
val comma3 = this.indexOf(',', comma2 + 1)
val comma4 = this.indexOf(',', comma3 + 1)
val r = this.substringAfter('(').substringBefore(',').toFloat()
val g = this.slice(comma1 + 1..comma2 - 1).toFloat()
val b = this.slice(comma2 + 1..comma3 - 1).toFloat()
val a = this.slice(comma3 + 1..comma4 - 1).toFloat()
return Color(r, g, b, a)
}