Search code examples
kotlinuser-interfaceandroid-jetpack-composeviewmodeldatastore

Jetpack compose Implementing unidirectional flow Datastore -> ViewModel -> UI


I am working on my first app. I use android jetpack compose and Kotlin. I am trying to persist some user preferences using datastore. All the resources I find regarding the use of datastore somehow use things like observables, livedata etc... I am trying to follow the tutorials for jetpack compose and use a unidirectional flow: data layer -> viewmodel -> UI. I cannot figure out how to make datastore work using this scheme.

My app is inspired from the inventory app from the compose tutorials. Like that app I have files called AppViewModelProvider, AppContainer etc... whose code can be found below. Now since this is my first app, I do not understand every detail of the code, I use them like blackboxes and mostly work through imitation.

The data I want to persist can be found on this screen

enter image description here

This screen contains 5 checkboxes which correspond to 5 booleans which are the user preferences I want to persist using datastore.

To try and make datastore work I have a file I have called 'DataStoreManager.kt' which contains a writing, reading and clearing functions .

import android.content.Context
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.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore
import com.example.lfc.ui.settings.SettingsDetails
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException

private const val LFC_DATASTORE = "user_preferences"

class DataStoreManager (
    private val dataStore: DataStore<Preferences>) {
        companion object PreferencesKeys{
        private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = LFC_DATASTORE)
        val FRENCH_BOOL = booleanPreferencesKey("FRENCH_BOOL")
        val ENGLISH_BOOL = booleanPreferencesKey("ENGLISH_BOOL")
        val ARABIC_BOOL = booleanPreferencesKey("ARABIC_BOOL")
        val JAPANESE_BOOL = booleanPreferencesKey("JAPANESE_BOOL")
        val ESPANOL_BOOL = booleanPreferencesKey("ESPANOL_BOOL")
    }
    suspend fun saveToDataStore(settingsDetails: SettingsDetails) {
        dataStore.edit {
            it[FRENCH_BOOL] = settingsDetails.frenchBool
            it[ENGLISH_BOOL] = settingsDetails.englishBool
            it[ARABIC_BOOL] = settingsDetails.arabicBool
            it[JAPANESE_BOOL] = settingsDetails.japaneseBool
            it[ESPANOL_BOOL] = settingsDetails.espanolBool
        }
    }
    fun getFromDataStore(): Flow<SettingsDetails> = dataStore.data
        .catch { exception ->
            if (exception is IOException) {
                emit(emptyPreferences())
            } else {
                throw exception
            }
        }
        .map {
        SettingsDetails(
            frenchBool  = it[FRENCH_BOOL]?:true,
            englishBool = it[ENGLISH_BOOL]?:true,
            arabicBool = it[ARABIC_BOOL]?:false,
            japaneseBool  = it[JAPANESE_BOOL]?:false,
            espanolBool = it[ESPANOL_BOOL]?:false,
        )
    }

    suspend fun clearDataStore() = dataStore.edit {
        it.clear()
    }
}

Now the screen above is related to a file called 'SettingsScreen.kt', which contains the UI, i can click and unclick the checkboxes and the UI works fine (graphically speaking).

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.lfc.LFCTopAppBar
import com.example.lfc.R
import com.example.lfc.data.DataStoreManager
import com.example.lfc.languageList
import com.example.lfc.languageListLabels
import com.example.lfc.ui.AppViewModelProvider
import com.example.lfc.ui.navigation.NavigationDestination
import kotlinx.coroutines.launch

object SettingsDestination : NavigationDestination {
    override val route = "settings"
    override val titleRes = R.string.settings_title
}




@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
    navigateBack: () -> Unit,
    onNavigateUp: () -> Unit,
    canNavigateBack: Boolean = true,
    viewModel: SettingsViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    val dataStoreManager = viewModel.dataStoreManager


    Scaffold(
        topBar = {
            LFCTopAppBar(
                title = stringResource(SettingsDestination.titleRes),
                canNavigateBack = canNavigateBack,
                navigateUp = onNavigateUp
            )
        }
    ) { innerPadding ->
        SettingsBody(
            dataStoreManager = dataStoreManager,
            settingsUiState = viewModel.settingsUiState,
            modifier = Modifier
                .padding(innerPadding)
                .verticalScroll(rememberScrollState())
                .fillMaxWidth()
        )
    }
}


@Composable
fun SettingsBody(
    dataStoreManager: DataStoreManager,
    settingsUiState: SettingsUiState,
    modifier: Modifier = Modifier
) {
    Log.d("scar3", settingsUiState.settingsDetails.toString())


    Column(
        modifier = modifier.padding(dimensionResource(id = R.dimen.padding_medium)),
        verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.padding_large))
    ) {
        for (index in languageList.indices) {LanguageCheckbox( settingsUiState, index, dataStoreManager)}

    }
}

@Composable
private fun LanguageCheckbox( settingsUiState: SettingsUiState, index: Int, dataStoreManager:DataStoreManager) {
    val coroutineScope = rememberCoroutineScope()
    Log.d("scar4", settingsUiState.settingsDetails.toString())
    var checked by remember{
        mutableStateOf(  when(index){
        0-> settingsUiState.settingsDetails.frenchBool
        1-> settingsUiState.settingsDetails.englishBool
        2-> settingsUiState.settingsDetails.arabicBool
        3-> settingsUiState.settingsDetails.japaneseBool
        4-> settingsUiState.settingsDetails.espanolBool
        else -> false
    })}
    Log.d("scar5", checked.toString())

    Column {
        Row(verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.fillMaxWidth()

        ) {
            Checkbox(
                checked = checked,
                onCheckedChange = {
                        isChecked ->
                            checked = isChecked
                            when(index){
                                0-> settingsUiState.settingsDetails.frenchBool=isChecked
                                1-> settingsUiState.settingsDetails.englishBool=isChecked
                                2-> settingsUiState.settingsDetails.arabicBool=isChecked
                                3-> settingsUiState.settingsDetails.japaneseBool=isChecked
                                4-> settingsUiState.settingsDetails.espanolBool=isChecked
                            }
                            coroutineScope.launch {
                                dataStoreManager.saveToDataStore( settingsUiState.settingsDetails)
                        }




                }

            )

            Text(languageListLabels[index])
        }

        }

}

This UI I am trying to drive using a viewModel 'SettingsViewModel.kt',

import android.content.Context
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.datastore.dataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.lfc.data.DataStoreManager
import com.example.lfc.languageList
import com.example.lfc.ui.home.HomeListUiState
import com.example.lfc.ui.home.HomeViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

// TODO insert in the right table depending on which collection I come from

class SettingsViewModel( val dataStoreManager: DataStoreManager) : ViewModel() {
// Here I try to initialize the UI state
    var settingsUiState by mutableStateOf( SettingsUiState())
        private set
//    what I am trying to do here is to listen to the flow within the ViewModel, 
//    and update the UIState using this flow, this is probably wrong...
    init {
        viewModelScope.launch {
            dataStoreManager.getFromDataStore().collect{
                settingsUiState.settingsDetails
            }
        }
    }

}



data class SettingsUiState( var settingsDetails: SettingsDetails = SettingsDetails())
data class SettingsDetails(
    var frenchBool: Boolean = true,
    var englishBool: Boolean = true,
    var arabicBool: Boolean= false,
    var japaneseBool: Boolean = false,
    var espanolBool: Boolean = false,
)

Anyway this does not work, but I was not expecting it too and when I go out of the settings screen and come back, the booleans (checkboxes) are set to their initial values. I know there are probably many nonsensical pieces of code in here, but I would be happy with any pointers as to how to do this, i.e. how to implement the relation DATASTORE-> VIEWMODEL -> UI ->VIEMODEL->DATASTORE using jetpack compose, and not those livedata . observable concepts which I do not understand and am not using in my app as they are not present in the Jetpack compose tutorials.

Other maybe relevant files, that may also be wrong

AppViewModelProvider.kt

import android.app.Application
import android.content.Context
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.preferencesDataStore
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.lfc.LFCApplication
import com.example.lfc.data.DataStoreManager
import com.example.lfc.ui.ItemEdit.ItemEditViewModel
import com.example.lfc.ui.ItemEntry.ItemEntryViewModel
import com.example.lfc.ui.TableEntry.TableEntryViewModel
import com.example.lfc.ui.home.HomeViewModel
import com.example.lfc.ui.settings.SettingsViewModel

/**
 * Provides Factory to create instance of ViewModel for the entire LFC app
 */
object AppViewModelProvider {

    val Factory = viewModelFactory {


        // Initializer for HomeViewModel
        initializer {
            HomeViewModel(lfcApplication().container.itemsRepository)
        }
//        // Initializer for ItemEditViewModel
        initializer {
            ItemEditViewModel(
                this.createSavedStateHandle(),
                lfcApplication().container.itemsRepository
            )
        }
        // Initializer for ItemEntryViewModel
        initializer {
            ItemEntryViewModel(lfcApplication().container.itemsRepository)
        }

        // Initializer for ItemEntryViewModel
        initializer {
            TableEntryViewModel()
        }

        // Initializer for ItemEntryViewModel
        initializer {
            SettingsViewModel(lfcApplication().container.dataStoreManager)
        }
    }
}

/**
 * Extension function to queries for [Application] object and returns an instance of
 * [LFCApplication].
 */
fun CreationExtras.lfcApplication(): LFCApplication =
    (this[AndroidViewModelFactory.APPLICATION_KEY] as LFCApplication)

AppContainer.kt

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore

private const val LFC_DATASTORE = "user_preferences"


/**
 * App container for Dependency injection.
 */
interface AppContainer {
    val itemsRepository: ItemsRepository
    val dataStoreManager: DataStoreManager

}

/**
 * [AppContainer] implementation that provides instance of [OfflineItemsRepository]
 */
class AppDataContainer(private val context: Context) : AppContainer {

    private val Context.dataStore by preferencesDataStore(
        name = LFC_DATASTORE
    )
    /**
     * Implementation for [ItemsRepository]
     */
    override val itemsRepository: ItemsRepository by lazy {
        OfflineItemsRepository(LFCDatabase.getDatabase(context).itemDao())
    }
    override val dataStoreManager: DataStoreManager by lazy {
        DataStoreManager(this.context.dataStore)
    }
}

LFCApplication.kt

import android.app.Application
import com.example.lfc.data.AppContainer
import com.example.lfc.data.AppDataContainer

class LFCApplication:  Application() {

    /**
     * AppContainer instance used by the rest of classes to obtain dependencies
     */
    lateinit var container: AppContainer

    override fun onCreate() {
        super.onCreate()
        container = AppDataContainer(this)
    }
}

MainActivity.kt

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.lfc.ui.theme.LFCTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LFCTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    LFCApp()
                }
            }
        }
    }
}

Solution

  • Firstly, in your viewModel, in current code you're not updating your settingsUiState you should do something like: ...collect { data -> settingsUiState.update { it.copy(settingsDetails = data)} }

    Second, for your unidirectional understanding, i suggest you to take a look at this video video. It is actually very long video but you can obtain a very good understanding about unidirectional dataflow from it. Though, a takeaway would be

    We usually use callbacks and lambdas in compose, we generally do not pass viewModels, repositories or datastores (in your case) to down to our composable tree. Instead, we create lambdas or take them as parameters in our composables and at the top of our composable tree, we have viewmodel methods that handle each action in our UI and then we pass them to lambdas we created as parameters in our child composables.