diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt index 5a245618..46f35091 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt @@ -2286,6 +2286,7 @@ | | | +--- org.jetbrains.androidx.core:core-bundle:1.0.1 (*) | | | +--- org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta03 (*) | | | +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 (*) +| | | +--- project :core:common (*) | | | +--- project :core:model (*) | | | +--- org.jetbrains.compose.ui:ui:1.8.2 (*) | | | +--- org.jetbrains.compose.foundation:foundation:1.8.2 (*) diff --git a/cmp-android/src/main/kotlin/cmp/android/app/AppThemeExtensions.kt b/cmp-android/src/main/kotlin/cmp/android/app/AppThemeExtensions.kt index 0068bf6d..74dff810 100644 --- a/cmp-android/src/main/kotlin/cmp/android/app/AppThemeExtensions.kt +++ b/cmp-android/src/main/kotlin/cmp/android/app/AppThemeExtensions.kt @@ -18,4 +18,5 @@ fun DarkThemeConfig.isDarkMode( DarkThemeConfig.FOLLOW_SYSTEM -> isSystemDarkMode DarkThemeConfig.DARK -> true DarkThemeConfig.LIGHT -> false + DarkThemeConfig.BASED_ON_TIME -> false } diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/AppViewModel.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/AppViewModel.kt index db7c2495..a066d076 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/AppViewModel.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/AppViewModel.kt @@ -17,9 +17,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.mifos.core.common.DateHelper import org.mifos.core.data.repository.UserDataRepository import org.mifos.core.model.DarkThemeConfig import org.mifos.core.model.LanguageConfig +import org.mifos.core.model.TimeBasedTheme import template.core.base.platform.garbage.GarbageCollectionManager import template.core.base.ui.BaseViewModel @@ -32,9 +34,20 @@ class AppViewModel( isAndroidTheme = false, isDynamicColorsEnabled = false, isScreenCaptureAllowed = false, + timeBasedTheme = TimeBasedTheme( + hourStart = 18, + hourEnd = 0, + minStart = 6, + minEnd = 0, + ), ), ) { init { + settingsRepository + .observeTimeBasedThemeConfig + .onEach { trySendAction(AppAction.Internal.TimeBasedThemeUpdate(it)) } + .launchIn(viewModelScope) + settingsRepository .observeDarkThemeConfig .onEach { trySendAction(AppAction.Internal.ThemeUpdate(it)) } @@ -70,6 +83,8 @@ class AppViewModel( is AppAction.Internal.CurrentUserStateChange -> handleCurrentUserStateChange() is AppAction.Internal.UserUnlockStateChange -> handleUserUnlockStateChange() + + is AppAction.Internal.TimeBasedThemeUpdate -> handleTimeBasedThemeUpdate(action) } } @@ -84,8 +99,18 @@ class AppViewModel( } private fun handleAppThemeUpdated(action: AppAction.Internal.ThemeUpdate) { + val timeBased = state.timeBasedTheme + var darkTheme = action.theme == DarkThemeConfig.DARK + if (action.theme == DarkThemeConfig.BASED_ON_TIME) { + darkTheme = DateHelper.isDarkModeBasedOnTime( + timeBased.hourStart, + timeBased.minStart, + timeBased.hourEnd, + timeBased.minEnd, + ) + } mutableStateFlow.update { - it.copy(darkTheme = action.theme == DarkThemeConfig.DARK) + it.copy(darkTheme = darkTheme) } sendEvent(AppEvent.UpdateAppTheme(osValue = action.theme.osValue)) } @@ -102,6 +127,14 @@ class AppViewModel( recreateUiAndGarbageCollect() } + private fun handleTimeBasedThemeUpdate(action: AppAction.Internal.TimeBasedThemeUpdate) { + mutableStateFlow.update { + it.copy( + timeBasedTheme = action.theme, + ) + } + } + private fun recreateUiAndGarbageCollect() { sendEvent(AppEvent.Recreate) garbageCollectionManager.tryCollect() @@ -113,6 +146,7 @@ data class AppState( val isAndroidTheme: Boolean, val isDynamicColorsEnabled: Boolean, val isScreenCaptureAllowed: Boolean, + val timeBasedTheme: TimeBasedTheme, ) sealed interface AppEvent { @@ -144,6 +178,10 @@ sealed interface AppAction { val theme: DarkThemeConfig, ) : Internal() + data class TimeBasedThemeUpdate( + val theme: TimeBasedTheme, + ) : Internal() + data object UserUnlockStateChange : Internal() data class DynamicColorsUpdate( diff --git a/core/common/src/commonMain/kotlin/org/mifos/core/common/DateHelper.kt b/core/common/src/commonMain/kotlin/org/mifos/core/common/DateHelper.kt new file mode 100644 index 00000000..1a24d8f8 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/mifos/core/common/DateHelper.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +package org.mifos.core.common + +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.ExperimentalTime + +object DateHelper { + + @OptIn(ExperimentalTime::class) + fun isDarkModeBasedOnTime( + startHour: Int, + startMinute: Int, + endHour: Int, + endMinute: Int, + ): Boolean { + val now = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .time + + val currentMinutes = now.hour * 60 + now.minute + val startMinutes = startHour * 60 + startMinute + val endMinutes = endHour * 60 + endMinute + + return if (startMinutes < endMinutes) { + // Same-day range (e.g., 06:00 → 18:00) + currentMinutes in startMinutes until endMinutes + } else { + // Cross-midnight range (e.g., 18:00 → 06:00) + currentMinutes !in endMinutes.. 12 + else -> hour % 12 + } + return "$hour12:${minute.toString().padStart(2, '0')} $period" + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifos/core/data/repository/UserDataRepository.kt b/core/data/src/commonMain/kotlin/org/mifos/core/data/repository/UserDataRepository.kt index 13c83795..3dbde658 100644 --- a/core/data/src/commonMain/kotlin/org/mifos/core/data/repository/UserDataRepository.kt +++ b/core/data/src/commonMain/kotlin/org/mifos/core/data/repository/UserDataRepository.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow import org.mifos.core.model.DarkThemeConfig import org.mifos.core.model.LanguageConfig import org.mifos.core.model.ThemeBrand +import org.mifos.core.model.TimeBasedTheme import org.mifos.core.model.UserData /** @@ -35,6 +36,8 @@ interface UserDataRepository { val observeDarkThemeConfig: Flow + val observeTimeBasedThemeConfig: Flow + val observeDynamicColorPreference: Flow val observeScreenCapturePreference: Flow @@ -45,6 +48,8 @@ interface UserDataRepository { suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) + suspend fun setTimeBasedThemeConfig(timeBasedThemeConfig: TimeBasedTheme) + suspend fun setDynamicColorPreference(useDynamicColor: Boolean) suspend fun setIsAuthenticated(isAuthenticated: Boolean) diff --git a/core/data/src/commonMain/kotlin/org/mifos/core/data/repositoryImpl/UserDataRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifos/core/data/repositoryImpl/UserDataRepositoryImpl.kt index 6eeb59db..b3720b17 100644 --- a/core/data/src/commonMain/kotlin/org/mifos/core/data/repositoryImpl/UserDataRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/mifos/core/data/repositoryImpl/UserDataRepositoryImpl.kt @@ -16,6 +16,7 @@ import org.mifos.core.datastore.UserPreferencesRepository import org.mifos.core.model.DarkThemeConfig import org.mifos.core.model.LanguageConfig import org.mifos.core.model.ThemeBrand +import org.mifos.core.model.TimeBasedTheme import org.mifos.core.model.UserData class UserDataRepositoryImpl( @@ -36,6 +37,9 @@ class UserDataRepositoryImpl( override val observeDarkThemeConfig: Flow get() = preferencesRepository.observeDarkThemeConfig + override val observeTimeBasedThemeConfig: Flow + get() = preferencesRepository.observeTimeBasedThemeConfig + override val observeDynamicColorPreference: Flow get() = preferencesRepository.observeDynamicColorPreference @@ -49,6 +53,9 @@ class UserDataRepositoryImpl( override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = preferencesRepository.setDarkThemeConfig(darkThemeConfig) + override suspend fun setTimeBasedThemeConfig(timeBasedThemeConfig: TimeBasedTheme) = + preferencesRepository.setTimeBasedThemeConfig(timeBasedThemeConfig) + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) = preferencesRepository.setDynamicColorPreference(useDynamicColor) diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepository.kt index 13c831ea..d5cac5f5 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepository.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepository.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow import org.mifos.core.model.DarkThemeConfig import org.mifos.core.model.LanguageConfig import org.mifos.core.model.ThemeBrand +import org.mifos.core.model.TimeBasedTheme import org.mifos.core.model.UserData /** @@ -35,6 +36,8 @@ interface UserPreferencesRepository { val observeDarkThemeConfig: Flow + val observeTimeBasedThemeConfig: Flow + val observeDynamicColorPreference: Flow val observeScreenCapturePreference: Flow @@ -45,6 +48,8 @@ interface UserPreferencesRepository { suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) + suspend fun setTimeBasedThemeConfig(timeBasedTheme: TimeBasedTheme) + suspend fun setDynamicColorPreference(useDynamicColor: Boolean) suspend fun setIsAuthenticated(isAuthenticated: Boolean) diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepositoryImpl.kt index 7601ebfa..10bfcd25 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepositoryImpl.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepositoryImpl.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import org.mifos.core.model.DarkThemeConfig import org.mifos.core.model.LanguageConfig import org.mifos.core.model.ThemeBrand +import org.mifos.core.model.TimeBasedTheme import org.mifos.core.model.UserData import template.core.base.common.manager.DispatcherManager @@ -62,6 +63,9 @@ class UserPreferencesRepositoryImpl( override val observeDarkThemeConfig: Flow get() = _userData.map { it.darkThemeConfig } + override val observeTimeBasedThemeConfig: Flow + get() = _userData.map { it.timeBasedTheme } + override val observeDynamicColorPreference: Flow get() = _userData.map { it.useDynamicColor } @@ -89,6 +93,14 @@ class UserPreferencesRepositoryImpl( _userData.value = newPreference } + override suspend fun setTimeBasedThemeConfig(timeBasedTheme: TimeBasedTheme) { + withContext(dispatcher.io) { + val newPreference = settings.getUserPreference().copy(timeBasedTheme = timeBasedTheme) + settings.putUserPreference(newPreference) + _userData.value = newPreference + } + } + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) = withContext(dispatcher.io) { val newPreference = settings.getUserPreference().copy(useDynamicColor = useDynamicColor) diff --git a/core/model/src/commonMain/kotlin/org/mifos/core/model/DarkThemeConfig.kt b/core/model/src/commonMain/kotlin/org/mifos/core/model/DarkThemeConfig.kt index bd393530..e7544a7b 100644 --- a/core/model/src/commonMain/kotlin/org/mifos/core/model/DarkThemeConfig.kt +++ b/core/model/src/commonMain/kotlin/org/mifos/core/model/DarkThemeConfig.kt @@ -13,6 +13,7 @@ enum class DarkThemeConfig(val configName: String, val osValue: Int) { FOLLOW_SYSTEM("Follow System", -1), LIGHT("Light", 1), DARK("Dark", 2), + BASED_ON_TIME("Based on Time", 3), ; companion object { diff --git a/core/model/src/commonMain/kotlin/org/mifos/core/model/TimeBasedTheme.kt b/core/model/src/commonMain/kotlin/org/mifos/core/model/TimeBasedTheme.kt new file mode 100644 index 00000000..c7d2c330 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifos/core/model/TimeBasedTheme.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +package org.mifos.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TimeBasedTheme( + val hourStart: Int, + val hourEnd: Int, + val minStart: Int, + val minEnd: Int, +) diff --git a/core/model/src/commonMain/kotlin/org/mifos/core/model/UserData.kt b/core/model/src/commonMain/kotlin/org/mifos/core/model/UserData.kt index bcefe830..f0e31972 100644 --- a/core/model/src/commonMain/kotlin/org/mifos/core/model/UserData.kt +++ b/core/model/src/commonMain/kotlin/org/mifos/core/model/UserData.kt @@ -26,6 +26,7 @@ data class UserData( val enableScreenCapture: Boolean, val isPasscodeEnabled: Boolean, val isBiometricsEnabled: Boolean, + val timeBasedTheme: TimeBasedTheme, ) { companion object { val DEFAULT = UserData( @@ -41,7 +42,13 @@ data class UserData( isBiometricsEnabled = false, showOnboarding = false, firstTimeUser = false, - enableScreenCapture = false, + enableScreenCapture = true, + timeBasedTheme = TimeBasedTheme( + hourStart = 18, + hourEnd = 6, + minStart = 0, + minEnd = 0, + ), ) } } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index fe9f87d4..95f17ac7 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.data) + implementation(projects.core.common) implementation(projects.core.model) implementation(projects.coreBase.ui) diff --git a/feature/settings/src/commonMain/composeResources/values/strings.xml b/feature/settings/src/commonMain/composeResources/values/strings.xml index 39c90dd2..173d1976 100644 --- a/feature/settings/src/commonMain/composeResources/values/strings.xml +++ b/feature/settings/src/commonMain/composeResources/values/strings.xml @@ -30,4 +30,15 @@ OK Change Theme Change Theme + + Based on Time + Dark mode + Light mode + + Choose Dark Mode Time + Dark mode starts at + Dark mode ends at + Apply Theme + Cancel + Ok diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsDialog.kt b/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsDialog.kt index 791f4517..888b4372 100644 --- a/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsDialog.kt @@ -29,6 +29,9 @@ import androidx.compose.material3.RadioButton 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role @@ -36,20 +39,24 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel +import org.mifos.core.common.DateHelper import org.mifos.core.model.DarkThemeConfig import org.mifos.core.model.ThemeBrand +import org.mifos.core.model.TimeBasedTheme import org.mifos.feature.settings.generated.resources.Res import org.mifos.feature.settings.generated.resources.feature_settings_brand_android import org.mifos.feature.settings.generated.resources.feature_settings_brand_default +import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_based_on_time import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_config_dark import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_config_light import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_config_system_default +import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_label import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_preference import org.mifos.feature.settings.generated.resources.feature_settings_dismiss_dialog_button_text import org.mifos.feature.settings.generated.resources.feature_settings_dynamic_color_no import org.mifos.feature.settings.generated.resources.feature_settings_dynamic_color_preference import org.mifos.feature.settings.generated.resources.feature_settings_dynamic_color_yes +import org.mifos.feature.settings.generated.resources.feature_settings_light_mode_label import org.mifos.feature.settings.generated.resources.feature_settings_loading import org.mifos.feature.settings.generated.resources.feature_settings_theme import org.mifos.feature.settings.generated.resources.feature_settings_title @@ -57,7 +64,7 @@ import org.mifos.feature.settings.generated.resources.feature_settings_title @Composable fun SettingsDialog( onDismiss: () -> Unit, - viewModel: SettingsViewmodel = koinViewModel(), + viewModel: SettingsViewmodel, ) { val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle() SettingsDialog( @@ -66,6 +73,7 @@ fun SettingsDialog( onChangeThemeBrand = viewModel::updateThemeBrand, onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference, onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig, + onChangeTimeBasedTheme = viewModel::updateTimeBasedThemeConfig, ) } @@ -74,11 +82,13 @@ fun SettingsDialog( settingsUiState: SettingsUiState, onDismiss: () -> Unit, onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit, + onChangeTimeBasedTheme: (timeBasedTheme: TimeBasedTheme) -> Unit, onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, modifier: Modifier = Modifier, supportDynamicColor: Boolean = supportsDynamicTheming(), ) { + var timeBasedThemeDialogVisible by rememberSaveable { mutableStateOf(false) } AlertDialog( properties = DialogProperties(usePlatformDefaultWidth = false), modifier = modifier.fillMaxWidth(0.8f), @@ -106,8 +116,28 @@ fun SettingsDialog( supportDynamicColor = supportDynamicColor, onChangeThemeBrand = onChangeThemeBrand, onChangeDynamicColorPreference = onChangeDynamicColorPreference, - onChangeDarkThemeConfig = onChangeDarkThemeConfig, + onChangeDarkThemeConfig = { + if (it == DarkThemeConfig.BASED_ON_TIME) { + timeBasedThemeDialogVisible = true + } else { + onChangeDarkThemeConfig(it) + } + }, ) + + if (timeBasedThemeDialogVisible) { + TimeBasedThemeDialog( + onDismiss = { + timeBasedThemeDialogVisible = false + }, + initialTheme = settingsUiState.settings.timeBasedTheme, + onConfirm = { + onChangeTimeBasedTheme(it) + onChangeDarkThemeConfig(DarkThemeConfig.BASED_ON_TIME) + timeBasedThemeDialogVisible = false + }, + ) + } } } } @@ -189,6 +219,28 @@ private fun ColumnScope.SettingsPanel( selected = settings.darkThemeConfig == DarkThemeConfig.DARK, onClick = { onChangeDarkThemeConfig(DarkThemeConfig.DARK) }, ) + SettingsDialogThemeChooserRow( + text = stringResource(Res.string.feature_settings_dark_mode_based_on_time) + + "\n" + stringResource(Res.string.feature_settings_dark_mode_label) + + " [" + + DateHelper.formatTimeRange( + settings.timeBasedTheme.hourStart, + settings.timeBasedTheme.minStart, + settings.timeBasedTheme.hourEnd, + settings.timeBasedTheme.minEnd, + ) + + "]\n" + + stringResource(Res.string.feature_settings_light_mode_label) + " [" + + DateHelper.formatTimeRange( + settings.timeBasedTheme.hourEnd, + settings.timeBasedTheme.minEnd, + settings.timeBasedTheme.hourStart, + settings.timeBasedTheme.minStart, + ) + + "]", + selected = settings.darkThemeConfig == DarkThemeConfig.BASED_ON_TIME, + onClick = { onChangeDarkThemeConfig(DarkThemeConfig.BASED_ON_TIME) }, + ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsScreen.kt b/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsScreen.kt index 9e8ad64d..2b8707fc 100644 --- a/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsScreen.kt @@ -9,20 +9,31 @@ */ package org.mifos.feature.settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -30,11 +41,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifos.core.common.DateHelper import org.mifos.core.designsystem.icon.AppIcons +import org.mifos.core.model.TimeBasedTheme import org.mifos.core.ui.scaffold.KptScaffold import org.mifos.feature.settings.generated.resources.Res +import org.mifos.feature.settings.generated.resources.feature_settings_apply_theme +import org.mifos.feature.settings.generated.resources.feature_settings_cancel import org.mifos.feature.settings.generated.resources.feature_settings_change_theme_placeholder_text import org.mifos.feature.settings.generated.resources.feature_settings_change_theme_text +import org.mifos.feature.settings.generated.resources.feature_settings_choose_dark_mode_time +import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_ends_at +import org.mifos.feature.settings.generated.resources.feature_settings_dark_mode_starts_at +import org.mifos.feature.settings.generated.resources.feature_settings_ok +import org.mifos.feature.settings.generated.resources.feature_settings_title import template.core.base.analytics.AnalyticsHelper import template.core.base.analytics.TrackScreenView import template.core.base.analytics.rememberAnalyticsHelper @@ -43,6 +64,7 @@ import template.core.base.analytics.rememberAnalyticsHelper internal fun SettingsScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier, + viewModel: SettingsViewmodel = koinViewModel(), ) { val analyticsHelper = rememberAnalyticsHelper() var showSettingsDialog by rememberSaveable { mutableStateOf(false) } @@ -53,6 +75,7 @@ internal fun SettingsScreen( analyticsHelper.logSettingsDialogVisible(false) showSettingsDialog = false }, + viewModel = viewModel, ) } @@ -75,7 +98,7 @@ internal fun SettingsScreenContent( modifier: Modifier = Modifier, ) { KptScaffold( - title = "Settings", + title = stringResource(Res.string.feature_settings_title), onNavigationIconClick = onBackClick, modifier = modifier, ) { @@ -101,9 +124,13 @@ internal fun ThemeCard( elevation = CardDefaults.cardElevation( defaultElevation = 1.dp, ), - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth() + .clickable(onClick = onClick), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Icon( imageVector = AppIcons.Sun, contentDescription = null, @@ -115,18 +142,177 @@ internal fun ThemeCard( text = stringResource(Res.string.feature_settings_change_theme_text), modifier = Modifier.weight(1F), ) - IconButton( - onClick = onClick, - ) { - Icon( - imageVector = AppIcons.ArrowRight, - contentDescription = stringResource(Res.string.feature_settings_change_theme_placeholder_text), + + Icon( + imageVector = AppIcons.ArrowRight, + contentDescription = stringResource(Res.string.feature_settings_change_theme_placeholder_text), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeBasedThemeDialog( + initialTheme: TimeBasedTheme, + onDismiss: () -> Unit, + onConfirm: (TimeBasedTheme) -> Unit, +) { + var showStartPicker by remember { mutableStateOf(false) } + var showEndPicker by remember { mutableStateOf(false) } + + var startHour by remember { mutableStateOf(initialTheme.hourStart) } + var startMinute by remember { mutableStateOf(initialTheme.minStart) } + + var endHour by remember { mutableStateOf(initialTheme.hourEnd) } + var endMinute by remember { mutableStateOf(initialTheme.minEnd) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(Res.string.feature_settings_choose_dark_mode_time), + style = MaterialTheme.typography.titleLarge, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + TimeRow( + label = stringResource(Res.string.feature_settings_dark_mode_starts_at), + time = DateHelper.format(startHour, startMinute), + onClick = { showStartPicker = true }, + ) + + TimeRow( + label = stringResource(Res.string.feature_settings_dark_mode_ends_at), + time = DateHelper.format(endHour, endMinute), + onClick = { showEndPicker = true }, ) } + }, + confirmButton = { + Button( + onClick = { + onConfirm( + TimeBasedTheme( + hourStart = startHour, + minStart = startMinute, + hourEnd = endHour, + minEnd = endMinute, + ), + ) + }, + ) { + Text(stringResource(Res.string.feature_settings_apply_theme)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + colors = androidx.compose.material3.ButtonDefaults.textButtonColors(), + ) { + Text(stringResource(Res.string.feature_settings_cancel)) + } + }, + ) + + if (showStartPicker) { + TimePickerDialog( + initialHour = startHour, + initialMinute = startMinute, + onDismiss = { showStartPicker = false }, + onConfirm = { h, m -> + startHour = h + startMinute = m + showStartPicker = false + }, + ) + } + + if (showEndPicker) { + TimePickerDialog( + initialHour = endHour, + initialMinute = endMinute, + onDismiss = { showEndPicker = false }, + onConfirm = { h, m -> + endHour = h + endMinute = m + showEndPicker = false + }, + ) + } +} + +@Composable +private fun TimeRow( + label: String, + time: String, + onClick: () -> Unit, +) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + tonalElevation = 1.dp, + ) { + Text( + text = time, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.titleMedium, + ) } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimePickerDialog( + initialHour: Int, + initialMinute: Int, + onDismiss: () -> Unit, + onConfirm: (Int, Int) -> Unit, +) { + val state = rememberTimePickerState( + initialHour = initialHour, + initialMinute = initialMinute, + is24Hour = true, + ) + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button( + onClick = { + onConfirm(state.hour, state.minute) + }, + ) { + Text(stringResource(Res.string.feature_settings_ok)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + colors = androidx.compose.material3.ButtonDefaults.textButtonColors(), + ) { + Text(stringResource(Res.string.feature_settings_cancel)) + } + }, + text = { + TimePicker(state = state) + }, + ) +} + private fun AnalyticsHelper.logSettingsDialogVisible(visible: Boolean) { logEvent( type = "settings_dialog_visible", diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsViewmodel.kt b/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsViewmodel.kt index dbe4854f..fcd4c498 100644 --- a/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsViewmodel.kt +++ b/feature/settings/src/commonMain/kotlin/org/mifos/feature/settings/SettingsViewmodel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import org.mifos.core.data.repository.UserDataRepository import org.mifos.core.model.DarkThemeConfig import org.mifos.core.model.ThemeBrand +import org.mifos.core.model.TimeBasedTheme import template.core.base.analytics.AnalyticsHelper class SettingsViewmodel( @@ -32,6 +33,7 @@ class SettingsViewmodel( brand = userDate.themeBrand, useDynamicColor = userDate.useDynamicColor, darkThemeConfig = userDate.darkThemeConfig, + timeBasedTheme = userDate.timeBasedTheme, ), ) } @@ -55,6 +57,12 @@ class SettingsViewmodel( } } + fun updateTimeBasedThemeConfig(timeBasedTheme: TimeBasedTheme) { + viewModelScope.launch { + settingsRepository.setTimeBasedThemeConfig(timeBasedTheme) + } + } + fun updateDynamicColorPreference(useDynamicColor: Boolean) { viewModelScope.launch { analyticsHelper.logDynamicColorPreferences(useDynamicColor) @@ -67,6 +75,7 @@ data class UserEditableSettings( val brand: ThemeBrand, val useDynamicColor: Boolean, val darkThemeConfig: DarkThemeConfig, + val timeBasedTheme: TimeBasedTheme, ) sealed interface SettingsUiState {