Skip to content

Commit 458c833

Browse files
feat: Implement theme selection for the project (#49)
* refactor(core:datastore): Implement real settings storage in core/datastore for theme and language # Conflicts: # cmp-android/prodRelease-badging.txt * feat: Implement theme selection for the project * Added Theme Card
1 parent e598500 commit 458c833

File tree

30 files changed

+844
-81
lines changed

30 files changed

+844
-81
lines changed

cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt

+3
Original file line numberDiff line numberDiff line change
@@ -1438,6 +1438,7 @@
14381438
| | +--- org.jetbrains.androidx.core:core-bundle:1.0.1 (*)
14391439
| | +--- org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10 (*)
14401440
| | +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 (*)
1441+
| | +--- project :core:model (*)
14411442
| | +--- project :core:common (*)
14421443
| | +--- project :core:datastore (*)
14431444
| | +--- project :feature:home
@@ -1526,6 +1527,8 @@
15261527
| | | +--- org.jetbrains.compose.components:components-resources:1.7.0-rc01 (*)
15271528
| | | +--- org.jetbrains.compose.components:components-ui-tooling-preview:1.7.0-rc01 (*)
15281529
| | | +--- org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3 (*)
1530+
| | | +--- project :core:datastore (*)
1531+
| | | +--- project :core:model (*)
15291532
| | | \--- org.jetbrains.kotlin:kotlin-parcelize-runtime:2.1.0 (*)
15301533
| | +--- org.jetbrains.compose.material3:material3:1.7.0-rc01 (*)
15311534
| | +--- org.jetbrains.compose.foundation:foundation:1.7.0-rc01 (*)

cmp-ios/iosApp/ContentView.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ struct ComposeView: UIViewControllerRepresentable {
1313
struct ContentView: View {
1414
var body: some View {
1515
ComposeView()
16-
.ignoresSafeArea(.keyboard) // Compose has own keyboard handler
16+
.ignoresSafeArea(edges: .all)
17+
.ignoresSafeArea(.keyboard) // .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
1718
}
1819
}
1920

cmp-navigation/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ kotlin {
1919
commonMain.dependencies {
2020
// Core Modules
2121
implementation(projects.core.data)
22+
implementation(projects.core.model)
2223
implementation(projects.core.common)
2324
implementation(projects.core.datastore)
2425

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
9+
*/
10+
package cmp.navigation
11+
12+
import androidx.lifecycle.ViewModel
13+
import androidx.lifecycle.viewModelScope
14+
import kotlinx.coroutines.flow.SharingStarted
15+
import kotlinx.coroutines.flow.StateFlow
16+
import kotlinx.coroutines.flow.map
17+
import kotlinx.coroutines.flow.onStart
18+
import kotlinx.coroutines.flow.stateIn
19+
import org.mifos.core.datastore.UserPreferencesRepository
20+
import org.mifos.core.model.DarkThemeConfig
21+
import org.mifos.core.model.ThemeBrand
22+
import org.mifos.core.model.UserData
23+
24+
class AppViewModel(
25+
settingsRepository: UserPreferencesRepository,
26+
) : ViewModel() {
27+
val uiState: StateFlow<AppUiState> = settingsRepository.userData
28+
.onStart {
29+
settingsRepository.getThemeBrand(ThemeBrand.DEFAULT)
30+
settingsRepository.getDarkThemeConfig(DarkThemeConfig.FOLLOW_SYSTEM)
31+
settingsRepository.getDynamicColorPreference(false)
32+
}
33+
.map { userDate ->
34+
AppUiState.Success(userDate)
35+
}
36+
.stateIn(
37+
scope = viewModelScope,
38+
started = SharingStarted.WhileSubscribed(5_000),
39+
initialValue = AppUiState.Loading,
40+
)
41+
}
42+
43+
sealed interface AppUiState {
44+
data object Loading : AppUiState
45+
data class Success(val userData: UserData) : AppUiState {
46+
override val shouldDisplayDynamicTheming = userData.useDynamicColor
47+
override val shouldUseAndroidTheme = when (userData.themeBrand) {
48+
ThemeBrand.DEFAULT -> false
49+
ThemeBrand.ANDROID -> true
50+
}
51+
52+
override fun shouldUseDarkTheme(isSystemInDarkTheme: Boolean): Boolean =
53+
when (userData.darkThemeConfig) {
54+
DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme
55+
DarkThemeConfig.LIGHT -> false
56+
DarkThemeConfig.DARK -> true
57+
}
58+
}
59+
60+
val shouldDisplayDynamicTheming: Boolean get() = true
61+
val shouldUseAndroidTheme: Boolean get() = false
62+
fun shouldUseDarkTheme(isSystemInDarkTheme: Boolean) = isSystemInDarkTheme
63+
}

cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt

+14-1
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
*/
1010
package cmp.navigation
1111

12+
import androidx.compose.foundation.isSystemInDarkTheme
1213
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.getValue
1315
import androidx.compose.ui.Modifier
16+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1417
import androidx.navigation.compose.rememberNavController
1518
import cmp.navigation.navigation.RootNavGraph
1619
import org.koin.compose.koinInject
20+
import org.koin.compose.viewmodel.koinViewModel
1721
import org.mifos.core.data.utils.NetworkMonitor
1822
import org.mifos.core.data.utils.TimeZoneMonitor
1923
import org.mifos.core.designsystem.theme.MifosTheme
@@ -24,7 +28,16 @@ fun ComposeApp(
2428
networkMonitor: NetworkMonitor = koinInject(),
2529
timeZoneMonitor: TimeZoneMonitor = koinInject(),
2630
) {
27-
MifosTheme {
31+
val viewModel: AppViewModel = koinViewModel()
32+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
33+
34+
val isSystemInDarkTheme = isSystemInDarkTheme()
35+
36+
MifosTheme(
37+
darkTheme = uiState.shouldUseDarkTheme(isSystemInDarkTheme),
38+
androidTheme = uiState.shouldUseAndroidTheme,
39+
shouldDisplayDynamicTheming = uiState.shouldDisplayDynamicTheming,
40+
) {
2841
RootNavGraph(
2942
networkMonitor = networkMonitor,
3043
timeZoneMonitor = timeZoneMonitor,

cmp-navigation/src/commonMain/kotlin/cmp/navigation/di/KoinModules.kt

+11
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
*/
1010
package cmp.navigation.di
1111

12+
import cmp.navigation.AppViewModel
13+
import org.koin.core.module.dsl.viewModelOf
1214
import org.koin.dsl.module
1315
import org.mifos.core.common.di.DispatchersModule
1416
import org.mifos.core.data.di.DataModule
17+
import org.mifos.core.datastore.di.DatastoreModule
18+
import org.mifos.feature.settings.SettingsModule
1519

1620
object KoinModules {
1721
private val dataModule = module {
@@ -22,8 +26,15 @@ object KoinModules {
2226
includes(DispatchersModule)
2327
}
2428

29+
private val AppModule = module {
30+
viewModelOf(::AppViewModel)
31+
}
32+
2533
val allModules = listOf(
2634
dataModule,
2735
dispatcherModule,
36+
DatastoreModule,
37+
SettingsModule,
38+
AppModule,
2839
)
2940
}

cmp-navigation/src/commonMain/kotlin/cmp/navigation/ui/App.kt

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable
3737
import androidx.compose.runtime.LaunchedEffect
3838
import androidx.compose.runtime.getValue
3939
import androidx.compose.runtime.remember
40+
import androidx.compose.runtime.setValue
4041
import androidx.compose.ui.Modifier
4142
import androidx.compose.ui.composed
4243
import androidx.compose.ui.draw.drawWithContent

core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepository.kt

+11-5
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@
99
*/
1010
package org.mifos.core.datastore
1111

12-
import org.mifos.core.datastore.model.SampleUser
12+
import kotlinx.coroutines.flow.Flow
13+
import org.mifos.core.model.DarkThemeConfig
14+
import org.mifos.core.model.ThemeBrand
15+
import org.mifos.core.model.UserData
1316

1417
interface UserPreferencesRepository {
18+
val userData: Flow<UserData>
1519

16-
suspend fun saveUser(key: String, user: SampleUser)
17-
suspend fun getUser(key: String, defaultValue: SampleUser): SampleUser
20+
suspend fun setThemeBrand(themeBrand: ThemeBrand)
21+
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
22+
suspend fun setDynamicColorPreference(useDynamicColor: Boolean)
1823

19-
suspend fun getDoubleNumber(key: String, defaultValue: Double): Double
20-
suspend fun saveDoubleNumber(key: String, number: Double)
24+
suspend fun getThemeBrand(themeBrand: ThemeBrand)
25+
suspend fun getDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
26+
suspend fun getDynamicColorPreference(useDynamicColor: Boolean)
2127
}

core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/UserPreferencesRepositoryImpl.kt

+43-23
Original file line numberDiff line numberDiff line change
@@ -9,39 +9,59 @@
99
*/
1010
package org.mifos.core.datastore
1111

12-
import org.mifos.core.datastore.model.SampleUser
12+
import kotlinx.coroutines.flow.Flow
13+
import kotlinx.coroutines.flow.MutableStateFlow
14+
import kotlinx.coroutines.flow.asStateFlow
15+
import org.mifos.core.model.DarkThemeConfig
16+
import org.mifos.core.model.ThemeBrand
17+
import org.mifos.core.model.UserData
1318
import org.mifos.corebase.datastore.UserPreferencesDataStore
1419

20+
private const val THEME_BRAND_KEY = "theme_brand"
21+
private const val DARK_THEME_CONFIG_KEY = "dark_theme_config"
22+
private const val DYNAMIC_COLOR_KEY = "use_dynamic_color"
23+
1524
class UserPreferencesRepositoryImpl(
1625
private val dataStore: UserPreferencesDataStore,
1726
) : UserPreferencesRepository {
18-
override suspend fun saveUser(
19-
key: String,
20-
user: SampleUser,
21-
) {
22-
dataStore.putValue(
23-
key = key,
24-
value = user,
25-
serializer = SampleUser.serializer(),
26-
)
27+
28+
private var _userData: MutableStateFlow<UserData> = MutableStateFlow(UserData())
29+
override val userData: Flow<UserData> = _userData.asStateFlow()
30+
31+
override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
32+
dataStore.putValue(key = THEME_BRAND_KEY, value = themeBrand.brandName)
33+
getThemeBrand(themeBrand)
34+
}
35+
36+
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
37+
dataStore.putValue(key = DARK_THEME_CONFIG_KEY, value = darkThemeConfig.name)
38+
getDarkThemeConfig(darkThemeConfig)
39+
}
40+
41+
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
42+
dataStore.putValue(key = DYNAMIC_COLOR_KEY, value = useDynamicColor)
43+
getDynamicColorPreference(useDynamicColor)
2744
}
2845

29-
override suspend fun getUser(
30-
key: String,
31-
defaultValue: SampleUser,
32-
): SampleUser {
33-
return dataStore.getValue(
34-
key = key,
35-
default = defaultValue,
36-
serializer = SampleUser.serializer(),
37-
)
46+
override suspend fun getThemeBrand(themeBrand: ThemeBrand) {
47+
val themeBrandString =
48+
dataStore.getValue(key = THEME_BRAND_KEY, default = ThemeBrand.DEFAULT.brandName)
49+
_userData.value = _userData.value.copy(themeBrand = ThemeBrand.fromString(themeBrandString))
3850
}
3951

40-
override suspend fun getDoubleNumber(key: String, defaultValue: Double): Double {
41-
return dataStore.getValue(key, defaultValue)
52+
override suspend fun getDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
53+
val darkThemeConfigString =
54+
dataStore.getValue(
55+
key = DARK_THEME_CONFIG_KEY,
56+
default = DarkThemeConfig.FOLLOW_SYSTEM.name,
57+
)
58+
_userData.value =
59+
_userData.value.copy(darkThemeConfig = DarkThemeConfig.fromString(darkThemeConfigString))
4260
}
4361

44-
override suspend fun saveDoubleNumber(key: String, number: Double) {
45-
dataStore.putValue(key, number)
62+
override suspend fun getDynamicColorPreference(useDynamicColor: Boolean) {
63+
val useDynamicColorBoolean =
64+
dataStore.getValue(key = DYNAMIC_COLOR_KEY, default = false)
65+
_userData.value = _userData.value.copy(useDynamicColor = useDynamicColorBoolean)
4666
}
4767
}

core/datastore/src/commonMain/kotlin/org/mifos/core/datastore/model/SampleUser.kt

-31
This file was deleted.

core/designsystem/src/commonMain/kotlin/org/mifos/core/designsystem/icon/AppIcons.kt

+6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ package org.mifos.core.designsystem.icon
1111

1212
import androidx.compose.material.icons.Icons
1313
import androidx.compose.material.icons.automirrored.filled.ArrowBack
14+
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
1415
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
1516
import androidx.compose.material.icons.filled.ArrowOutward
1617
import androidx.compose.material.icons.filled.AttachMoney
@@ -54,6 +55,7 @@ import androidx.compose.material.icons.outlined.Share
5455
import androidx.compose.material.icons.outlined.Visibility
5556
import androidx.compose.material.icons.outlined.VisibilityOff
5657
import androidx.compose.material.icons.outlined.Wallet
58+
import androidx.compose.material.icons.outlined.WbSunny
5759
import androidx.compose.material.icons.rounded.AccountBalance
5860
import androidx.compose.material.icons.rounded.AccountCircle
5961
import androidx.compose.material.icons.rounded.Add
@@ -98,6 +100,7 @@ object AppIcons {
98100
val OutlinedShare = Icons.Outlined.Share
99101
val ArrowBack = Icons.AutoMirrored.Filled.ArrowBack
100102
val ArrowBack2 = Icons.Filled.ChevronLeft
103+
val ArrowRight = Icons.AutoMirrored.Filled.KeyboardArrowRight
101104
val Cancel = Icons.Outlined.Cancel
102105
val AccountCircle = Icons.Outlined.AccountCircle
103106
val SendRightTilted = Icons.Default.ArrowOutward
@@ -126,4 +129,7 @@ object AppIcons {
126129
val Scan = Icons.Outlined.QrCodeScanner
127130
val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked
128131
val RadioButtonChecked = Icons.Filled.RadioButtonChecked
132+
133+
// val Theme = Icons.Filled.WbSunny
134+
val Sun = Icons.Outlined.WbSunny
129135
}

core/designsystem/src/commonMain/kotlin/org/mifos/core/designsystem/theme/Color.kt

+36
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,39 @@ val surfaceContainerLowDarkHighContrast = Color(0xFF1B1B24)
226226
val surfaceContainerDarkHighContrast = Color(0xFF1F1F28)
227227
val surfaceContainerHighDarkHighContrast = Color(0xFF292932)
228228
val surfaceContainerHighestDarkHighContrast = Color(0xFF34343D)
229+
230+
// Android theme colors
231+
internal val DarkGreen10 = Color(0xFF0D1F12)
232+
internal val DarkGreen20 = Color(0xFF223526)
233+
internal val DarkGreen30 = Color(0xFF394B3C)
234+
internal val DarkGreen40 = Color(0xFF4F6352)
235+
internal val DarkGreen80 = Color(0xFFB7CCB8)
236+
internal val DarkGreen90 = Color(0xFFD3E8D3)
237+
internal val DarkGreenGray10 = Color(0xFF1A1C1A)
238+
internal val DarkGreenGray20 = Color(0xFF2F312E)
239+
internal val DarkGreenGray90 = Color(0xFFE2E3DE)
240+
internal val DarkGreenGray95 = Color(0xFFF0F1EC)
241+
internal val DarkGreenGray99 = Color(0xFFFBFDF7)
242+
internal val Green10 = Color(0xFF00210B)
243+
internal val Green20 = Color(0xFF003919)
244+
internal val Green30 = Color(0xFF005227)
245+
internal val Green40 = Color(0xFF006D36)
246+
internal val Green80 = Color(0xFF0EE37C)
247+
internal val Green90 = Color(0xFF5AFF9D)
248+
internal val GreenGray30 = Color(0xFF414941)
249+
internal val GreenGray50 = Color(0xFF727971)
250+
internal val GreenGray60 = Color(0xFF8B938A)
251+
internal val GreenGray80 = Color(0xFFC1C9BF)
252+
internal val GreenGray90 = Color(0xFFDDE5DB)
253+
internal val Red10 = Color(0xFF410002)
254+
internal val Red20 = Color(0xFF690005)
255+
internal val Red30 = Color(0xFF93000A)
256+
internal val Red40 = Color(0xFFBA1A1A)
257+
internal val Red80 = Color(0xFFFFB4AB)
258+
internal val Red90 = Color(0xFFFFDAD6)
259+
internal val Teal10 = Color(0xFF001F26)
260+
internal val Teal20 = Color(0xFF02363F)
261+
internal val Teal30 = Color(0xFF214D56)
262+
internal val Teal40 = Color(0xFF3A656F)
263+
internal val Teal80 = Color(0xFFA2CED9)
264+
internal val Teal90 = Color(0xFFBEEAF6)

0 commit comments

Comments
 (0)