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.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme.colorScheme
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.NavItem
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.timerScreen.viewModel.TimerViewModel
import org.nsh07.pomodoro.utils.toColor
class MainActivity : ComponentActivity() {
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 {
(application as TomatoApplication).container
@@ -27,14 +31,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
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
LaunchedEffect(colorScheme) {
appContainer.appTimerRepository.colorScheme = colorScheme
}
timerViewModel.setCompositionLocals(colorScheme)
AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel)
AppScreen(timerViewModel = timerViewModel)
}
}
}

View File

@@ -46,7 +46,6 @@ import org.nsh07.pomodoro.MainActivity.Companion.screens
import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
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.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
@@ -56,8 +55,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@Composable
fun AppScreen(
modifier: Modifier = Modifier,
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory),
statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory)
) {
val context = LocalContext.current
@@ -194,7 +192,6 @@ fun AppScreen(
entry<Screen.Stats> {
StatsScreenRoot(
contentPadding = contentPadding,
viewModel = statsViewModel,
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(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.SliderState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -20,8 +21,11 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository
@@ -32,6 +36,9 @@ class SettingsViewModel(
private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository
) : ViewModel() {
private val _preferencesState = MutableStateFlow(PreferencesState())
val preferencesState = _preferencesState.asStateFlow()
val focusTimeTextFieldState =
TextFieldState((timerRepository.focusTime / 60000).toString())
val shortBreakTimeTextFieldState =
@@ -56,6 +63,22 @@ class SettingsViewModel(
preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged()
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) {
snapshotFlow { focusTimeTextFieldState.text }
.debounce(500)

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
package org.nsh07.pomodoro.utils
import androidx.compose.ui.graphics.Color
import java.util.Locale
import java.util.concurrent.TimeUnit
@@ -36,4 +37,26 @@ fun millisecondsToHoursMinutes(t: Long): String {
"%dh %dm", TimeUnit.MILLISECONDS.toHours(t),
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)
}