feat: Add a system for changing app theme
Changes are not user-facing yet. #30
This commit is contained in:
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user