Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ ConversionApp follows a scalable and testable architecture:
* `feature:sync` fetches the latest rates from the remote API and updates the database.
* `feature:commission` applies dynamic commission logic (e.g. first 5 free, 0.7% after).
* `feature:balance` manages the user's wallet-like balance.
* `feature:settings` stores preferences like the dark theme toggle.
* `core:network`, `core:datastore`, `core:database`, and `core:designsystem` are reusable base modules shared across features.

Each feature module depends only on what it needs and interacts with shared core modules through interfaces.
Expand Down
6 changes: 3 additions & 3 deletions core/datastore/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Datastore Module

This module wraps **Jetpack DataStore** so other modules can persist small pieces of data without needing to know the underlying implementation. Currently it stores a hash of the downloaded currency rates which the sync feature uses to determine when new data is available.
This module wraps **Jetpack DataStore** so other modules can persist small pieces of data without needing to know the underlying implementation. It stores the hash of the downloaded currency rates and also exposes helpers for persisting the user's dark theme preference.

## Overview

- **AppDataStore** – interface that exposes `getCurrencyRateHash()` and `saveCurrencyRateHash()`.
- **AppDataStore** – interface that exposes `getCurrencyRateHash()`, `saveCurrencyRateHash()`, `isDarkThemeEnabled()` and `setDarkThemeEnabled()`.
- **LocalDataSource** – `AppDataStore` implementation backed by `androidx.datastore.preferences`.
- **dataStoreModule** – Koin module that provides a `DataStore<Preferences>` instance named `secure_prefs` and binds `AppDataStore` to `LocalDataSource`.

Expand All @@ -19,4 +19,4 @@ startKoin {
}
```

Other modules can then inject `AppDataStore` to read or write the saved hash.
Other modules can then inject `AppDataStore` to persist the currency rate hash or the user's dark theme setting.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.thesetox.datastore

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
Expand All @@ -24,7 +25,18 @@ interface AppDataStore {
*
* @param hash value to store
*/

suspend fun saveCurrencyRateHash(hash: String)

/**
* Check whether dark theme is enabled.
*/
suspend fun isDarkThemeEnabled(): Boolean

/**
* Persist the dark theme preference.
*/
suspend fun setDarkThemeEnabled(enabled: Boolean)
}

/**
Expand All @@ -45,8 +57,20 @@ class LocalDataSource(private val dataStore: DataStore<Preferences>) : AppDataSt
}
}

override suspend fun isDarkThemeEnabled(): Boolean {
val preferences = dataStore.data.first()
return preferences[DARK_THEME_KEY] ?: false
}

override suspend fun setDarkThemeEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[DARK_THEME_KEY] = enabled
}
}

companion object {
/** Preference key used to store the currency rate hash. */
private val HASH_KEY = stringPreferencesKey("HASH_KEY")
private val DARK_THEME_KEY = booleanPreferencesKey("dark_theme")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ class LocalDataSourceTest {
// Act
localDataSource.saveCurrencyRateHash("abc")

// Assert
verify(dataStore).edit(any())
}

@Test
fun `isDarkThemeEnabled reads from dataStore`() =
runTest {
// Arrange
whenever(dataStore.data).thenReturn(flowOf(emptyPreferences()))

// Act
localDataSource.isDarkThemeEnabled()

// Assert
verify(dataStore).data
}

@Test
fun `setDarkThemeEnabled writes to dataStore`() =
runTest {
// Arrange
whenever(dataStore.edit(any()))
.thenReturn(emptyPreferences())

// Act
localDataSource.setDarkThemeEnabled(true)

// Assert
verify(dataStore).edit(any())
}
Expand Down
4 changes: 4 additions & 0 deletions feature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ Handles the currency exchange flow, including Compose UI components and several
## sync

Responsible for synchronizing currency rates from the remote API. `SyncUseCase` checks if an update is needed, saves rates to the database and stores an MD5 hash to avoid unnecessary work. A background `SyncService` triggers the process.

## settings

Stores user preferences such as whether dark theme is enabled. It exposes simple use cases via `settingsModule`.
13 changes: 13 additions & 0 deletions feature/settings/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Settings Module

This module stores simple user preferences for **ConversionApp**. It currently keeps a single flag indicating whether dark theme is enabled.

## Components

- `SettingsRepository` – interface for reading and updating preferences.
- `SettingsDataRepository` – DataStore based implementation.
- `GetDarkThemeEnabledUseCase` – returns the saved dark theme value.
- `SetDarkThemeEnabledUseCase` – updates the preference.
- `settingsModule` – Koin module exposing the repository and use cases.

Include `settingsModule` in your Koin setup and depend on `:feature:settings` to read or change the dark theme setting.
42 changes: 42 additions & 0 deletions feature/settings/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}

android {
namespace = "com.thesetox.settings"
compileSdk = 35

defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}

dependencies {
implementation(projects.core.datastore)
implementation(libs.koin.android)
implementation(libs.koin.core)

testImplementation(libs.junit)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.kotlinx.coroutines.test)
}
Empty file.
21 changes: 21 additions & 0 deletions feature/settings/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.thesetox.settings

/**
* Retrieves the saved dark theme setting.
*/
class GetDarkThemeEnabledUseCase(
private val repository: SettingsRepository,
) {
suspend operator fun invoke(): Boolean = repository.isDarkThemeEnabled()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.thesetox.settings

/**
* Updates the stored dark theme preference.
*/
class SetDarkThemeEnabledUseCase(
private val repository: SettingsRepository,
) {
suspend operator fun invoke(enabled: Boolean) {
repository.setDarkThemeEnabled(enabled)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.thesetox.settings

import com.thesetox.datastore.AppDataStore

/**
* DataStore-based implementation of [SettingsRepository].
*/
class SettingsDataRepository(
private val dataStore: AppDataStore,
) : SettingsRepository {
override suspend fun isDarkThemeEnabled(): Boolean {
return dataStore.isDarkThemeEnabled()
}

override suspend fun setDarkThemeEnabled(enabled: Boolean) {
dataStore.setDarkThemeEnabled(enabled)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.thesetox.settings

import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

/**
* Koin module that exposes the settings repository and use cases.
*/
val settingsModule =
module {
singleOf(::SettingsDataRepository) { bind<SettingsRepository>() }
singleOf(::GetDarkThemeEnabledUseCase)
singleOf(::SetDarkThemeEnabledUseCase)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.thesetox.settings

/**
* Repository interface for storing and retrieving user settings.
*/
interface SettingsRepository {
/**
* Returns whether dark theme is enabled.
*/
suspend fun isDarkThemeEnabled(): Boolean

/**
* Persists the dark theme preference.
*/
suspend fun setDarkThemeEnabled(enabled: Boolean)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.thesetox.settings

import com.thesetox.datastore.AppDataStore
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

// Tests verifying the repository delegates to the underlying datastore.
class SettingsDataRepositoryTest {
private val dataStore: AppDataStore = mock()
private val repository = SettingsDataRepository(dataStore)

@Test
fun `isDarkThemeEnabled delegates to datastore`() =
runTest {
// Arrange
whenever(dataStore.isDarkThemeEnabled()).thenReturn(false)

// Act
repository.isDarkThemeEnabled()

// Assert
verify(dataStore).isDarkThemeEnabled()
}

@Test
fun `setDarkThemeEnabled delegates to datastore`() =
runTest {
// Act
repository.setDarkThemeEnabled(true)

// Assert
verify(dataStore).setDarkThemeEnabled(true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.thesetox.settings

import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

class SettingsUseCaseTest {
private val repository: SettingsRepository = mock()

@Test
fun `saving preference updates repository`() =
runTest {
// Arrange
val setUseCase = SetDarkThemeEnabledUseCase(repository)

// Act
setUseCase(true)

// Assert
verify(repository).setDarkThemeEnabled(true)
}

@Test
fun `getting preference delegates to repository`() =
runTest {
// Arrange
whenever(repository.isDarkThemeEnabled()).thenReturn(true)
val getUseCase = GetDarkThemeEnabledUseCase(repository)

// Act
getUseCase()

// Assert
verify(repository).isDarkThemeEnabled()
}
}
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ include(":feature:sync")
include(":feature:exchange")
include(":feature:balance")
include(":feature:comission")
include(":feature:settings")