Composable visibility not changing on State change - android

I have a isLoading state and I'm trying to show a CircularProgressIndicator when the value is true.
#Composable
fun ProductDetailScreen(
viewModel: ProductDetailViewModel = hiltViewModel()
) {
val productState = viewModel.productState.value
LazyColumn{
item {
if (productState.isLoading)
CircularProgressIndicator()
}
}
}
I'm using a Resource class for my API call results and in the repository I use this class to wrap my request result.
The problem is, although I'm returning Resource.Loading from the repository, the isLoading state is not being updated from ViewModel and the ProgressIndicator is not shown in my screen. What could be causing this behavior?
sealed class Resource<T>(
val data: T? = null,
val message: String? = null,
val errorType: ExceptionMapper.Type? = null
) {
class Success<T>(data: T?) : Resource<T>(data)
class Error<T>(message: String, errorType: ExceptionMapper.Type, data: T? = null) : Resource<T>(data, message, errorType)
class Loading<T>(isLoading: Boolean = true) : Resource<T>()
}
Repository:
override suspend fun getProductComments(productId: Int): Resource<List<Comment>> {
return try {
Resource.Loading<List<Comment>>()
delay(3000)
Resource.Success(apiService.getComments(productId))
} catch (t: Throwable) {
val mappedException = ExceptionMapper.map(t)
Resource.Error(message = t.message!!, errorType = mappedException.type)
}
}
ViewModel:
#HiltViewModel
class ProductDetailViewModel #Inject constructor(
state: SavedStateHandle,
private val productRepository: ProductRepository
) : ViewModel() {
private val passedProduct = state.get<Product>(EXTRA_KEY_DATA)
var productId = passedProduct?.id
var productState = mutableStateOf(ProductState())
private set
init {
getProductComments()
}
private fun getProductComments() {
viewModelScope.launch {
productId?.let { pId ->
when (val commentResult = productRepository.getProductComments(pId)) {
is Resource.Success -> {
commentResult.data?.let { comments ->
productState.value =
productState.value.copy(
comments = comments,
error = null,
isLoading = false
)
}
}
is Resource.Error -> {
productState.value = productState.value.copy(
isLoadFailed = true,
isLoading = false,
error = commentResult.message
)
}
is Resource.Loading -> {
productState.value = productState.value.copy(
isLoadFailed = false,
isLoading = true,
error = null
)
}
}
}
}
}
}

Your'e only checking this
is Resource.Loading -> {
...
}
when the repository returns, at this point its useless because when the call to getProductComments is done, it's already Resource.Success.
return try {
Resource.Loading<List<Comment>>() // you'll never get this value
delay(3000)
Resource.Success(apiService.getComments(productId))
So I'd suggest to update the ProductState before you call the repository
private fun getProductComments() {
productState.value = productState.value.copy(isLoading = true)
viewModelScope.launch {
...
...
or set isLoading to true as its initial state.
data class ProductState(
...
...
val isLoading : Boolean = true
...
)

Related

How to write unit tests for paging library in android

I need to write unit tests for doing a paginated network request. First I implemented paging library logic to retrieve the data.
I created a data source class and a repository class to get data from the network request.
This is my data source class
class ListDataSource(
private val networkService: NetworkService,
private val searchKey: String) : PagingSource<Int, ListItem>() {
override fun getRefreshKey(state: PagingState<Int, ListItem>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ListItem> {
val pageNumber: Int = params.key ?: 0
return try {
val response = networkService.getList(
searchTerm = searchKey,
page = pageNumber,
size = 30
)
val listItems = response.response?.list
val nextKey = listItems?.let { nonNullList ->
if (nonNullList.size < 30) {
null
} else {
pageNumber + 1
}
} ?: run {
null
}
LoadResult.Page(
data = listItems.orEmpty(),
prevKey = if (pageNumber == 0) null else pageNumber - 1,
nextKey = nextKey
)
} catch (exception: Exception) {
LoadResult.Error(exception)
}
}}
This is my repository class
class ListPagingRepository(private val service: NetworkService) {
private lateinit var pager: Pager<Int, ListItem>
private lateinit var pagingSource: ListDataSource
fun getListPager(): Pager<Int, ListItem> {
return pager
}
fun isPagerInitialized(): Boolean = this::pager.isInitialized
fun createSource(searchTerm: String) {
pagingSource = ListDataSource(service, searchTerm)
}
fun createPager() {
pager = Pager(
config = PagingConfig(
initialLoadSize = 15,
pageSize = 15,
enablePlaceholders = false,
prefetchDistance = 2
),
pagingSourceFactory = { pagingSource }
)
}}
Inside my viewmodel I the function to do the network call is:
fun getPaginatedList(searchTerm: String): Flow<PagingData<ListItem>> {
listPagingRepository.createSource(searchTerm)
listPagingRepository.createPager()
return if (listPagingRepository.isPagerInitialized()) {
listPagingRepository
.getListPager()
.flow
.cachedIn(viewModelScope)
.map { pagingData -> pagingData.map { listMapper.map(it) } }
} else emptyFlow()
}
How can I test this network request?
Searched for 2 days but nothing that I found helped me.
You should test your implementation, with mocked network responses. An example of such a test with MockK as the mocking framework :
#Test
fun `verify error return from load() when a (types of responses you expect to potentially receive) response is returned` () = runTest {
val mockNetworkService = mockk<NetworkService>(relaxed = true, relaxedUnitFun = true)
val testParams = LoadParams(1, false)
every { mockNetworkService.getList(any(), any(), any()) } returns LoadResponse.Error(YourExpectedException)
val result = ListDataSource(mockNetworkService, "test search").load(testParams)
assertEquals(LoadResponse.Error(YourExpectedException), result)
}

StateFlow is updated but not collected

I'm currently learning about the new Android stack (MVVM, compose, kotlin Flow/StateFlow), and I'm having trouble debugging a StateFlow where the value is updated, but I have no sign of collection from the composable.
It's a generic question, but I didn't find any solution to my problem by searching on my own.
Does anybody have an idea about what could disturb a StateFlow? I'm letting my code below:
ViewModel:
#HiltViewModel
class AuthViewModel #Inject constructor(
private val navigationManager: NavigationManager,
private val interactor: AuthInteractor
): BaseViewModel() {
companion object {
val TAG: String = AuthViewModel::class.java.simpleName
}
private val _uiState = MutableStateFlow(AuthenticationState())
val uiState: StateFlow<AuthenticationState> = _uiState
fun handleEvent(event: AuthenticationEvent) {
Log.v(TAG, "new event: $event")
when (event) {
is AuthenticationEvent.GoToRegistering -> navigateToRegistering()
is AuthenticationEvent.Register -> registerAccount(event)
is AuthenticationEvent.SnackbarMessage -> showSnackBar(event.message, event.type)
}
}
private fun navigateToRegistering() {
navigationManager.navigate(NavigationDirections.Authentication.registering)
}
private fun registerAccount(event: AuthenticationEvent.Register) {
Log.v(TAG, "register account")
_uiState.value.build {
isLoading = true
}
viewModelScope.launch {
Log.v(TAG, "launching request")
val exceptionMessage = interactor.registerUser(event.login, event.password)
Log.v(TAG, "response received, launching state from viewmodel")
_uiState.value.build {
exceptionMessage?.let {
Log.v(TAG, "Exception is not null")
isFailure = true
snackbarMessage = interactor.selectRegisterError(it)
}
}
}
}
}
UI Component:
#Composable
fun Authentication(
modifier: Modifier = Modifier,
viewModel: AuthViewModel,
type: AuthenticationType
) {
val state by viewModel.uiState.collectAsState()
Log.v("Authentication", "new state: $state, type = $type")
BackHandler(enabled = true) {
viewModel.handleEvent(AuthenticationEvent.Back)
}
state.snackbarMessage?.let { resourceId ->
val message = stringResource(resourceId)
Log.e("Authentication", "error: $message")
viewModel.handleEvent(AuthenticationEvent.SnackbarMessage(message, SnackbarType.ERROR))
}
if (state.isAuthenticated) {
// TODO: launch main screen
}
LaunchedEffect(key1 = state.isRegistered) {
// TODO: launch login event
}
AuthenticationContent(
modifier = modifier,
type = type,
viewModel = viewModel,
state = state
)
}
State Data Class:
data class AuthenticationState(
val isLoading: Boolean = false,
val isFailure: Boolean = false,
val isRegistered: Boolean = false,
val isAuthenticated: Boolean = false,
#StringRes val snackbarMessage: Int? = null
) {
fun build(block: Builder.() -> Unit) = Builder(this).apply(block).build()
class Builder(uiModel: AuthenticationState) {
var isLoading = uiModel.isLoading
var isFailure = uiModel.isFailure
var isRegistered = uiModel.isRegistered
var isAuthenticated = uiModel.isAuthenticated
var snackbarMessage = uiModel.snackbarMessage
fun build(): AuthenticationState {
return AuthenticationState(
isLoading,
isFailure,
isRegistered,
isAuthenticated,
snackbarMessage
)
}
}
}
You are doing the updates like this:
_uiState.value.build {
isLoading = true
}
This code takes current value of uiState and calls build function on it. Build function creates new instance of AuthenticationState, but you don't do anything with that new instance and it's discarded. You have to set this new instance to be the new value of your StateFlow, like this:
_uiState.value = _uiState.value.build {}
// or this:
_uiState.update { current -> current.build {} }

Preferences are neither read nor written

I am working on app settings. I am doing this in Jetpack Compose. However, when the switch button is clicked and changed to true or false, this is printed out during debug, however, the settings don't appear to change nor be saved. No way to confirm this.
Not sure of LaunchEffect{} shall be used.
AppSettings:
import kotlinx.serialization.Serializable
#Serializable
data class AppSettings(
val enableLocation: Boolean = false
)
AppSettingsSerializer:
object AppSettingsSerializer : Serializer<AppSettings> {
override val defaultValue: AppSettings
get() = AppSettings()
override suspend fun readFrom(input: InputStream): AppSettings {
return try {
Json.decodeFromString(
deserializer = AppSettings.serializer(),
string = input.readBytes().decodeToString())
} catch (e: SerializationException){
e.printStackTrace()
defaultValue
}
}
override suspend fun writeTo(t: AppSettings, output: OutputStream) {
output.write(
Json.encodeToString(
serializer = AppSettings.serializer(),
value = t)
.encodeToByteArray()
)
}
}
SettingsViewModel:
#HiltViewModel
class SettingsViewModel #Inject constructor(
val preferencesRepository: PreferencesRepository
) : ViewModel() {
var preferences by mutableStateOf(AppSettings())
private set
init {
preferencesRepository.data
.onEach { preferences = it }
.launchIn(viewModelScope)
}
inline fun updatePreferences(crossinline body: (AppSettings) -> AppSettings) {
viewModelScope.launch {
val data = body(preferences)
preferencesRepository.updateSettings(data)
}
}
}
PreferenceRepository:
class PreferencesRepository(context: Context){
private val Context.dataStore by dataStore(
fileName = "app-settings.json",
serializer = AppSettingsSerializer
)
private val appDataStore = context.dataStore
val data = appDataStore.data
suspend fun updateSettings(settings: AppSettings) {
appDataStore.updateData { settings }
}
}
Inside settings screen:
item {
SwitchPreference(
title = stringResource(R.string.location),
subtitle = AnnotatedString(stringResource(R.string.location_desc)),
checked = settings.enableLocation,
onCheckedChange = {location ->
viewModel.updatePreferences { it.copy(enableLocation = location) }
}
)
}

How to retrieve data from Firestore with the MVVM pattern

I am creating an android application following the MVVM patron with the goal of retrieving data from a Firebase collection.
Before applying this pattern, I did proof of concept and I was able to retrieve data from the Firebase collection. But once I apply MVVM, I am not able to get the data from that collection, my screen does not show anything. I am not able to return the data from the repository to be painted on the screen.
This is my code:
Model:
data class PotatoesData(
val modifiedDate: String,
var potatoes: List<Potato>
)
data class Potato(
val type: String,
val site: String
)
State:
data class PotatoesState(
val isLoading: Boolean = false,
val potatoes: List<Potato> = emptyList(),
val error: String = ""
)
ModelView:
#HiltViewModel
class PotatoesViewModel #Inject constructor(
private val getPotatoesDataUseCase: GetPotatoesData
) : ViewModel() {
private val _state = mutableStateOf(PotatoesState())
val state: State<PotatoesState> = _state
init {
getPotatoes()
}
private fun getPotatoes() {
getPotatoesDataUseCase().onEach { result ->
when (result) {
is Resource.Success -> {
_state.value = PotatoesState(potatoes = result.data?.potatoes ?: emptyList())
}
is Resource.Error -> {
_state.value = PotatoesState(
error = result.message ?: "An unexpected error occurred"
)
}
is Resource.Loading -> {
_state.value = PotatoesState(isLoading = true)
}
}
}.launchIn(viewModelScope)
}
}
UseCase:
class GetPotatoesData #Inject constructor(
private val repository: PotatoRepository
) {
operator fun invoke(): Flow<Resource<PotatoesData>> = flow {
try {
emit(Resource.Loading())
val potatoes = repository.getPotatoesData()
emit(Resource.Success(potatoes))
} catch (e: IOException) {
emit(Resource.Error("Couldn't reach server. Check your internet connection."))
}
}
}
Repository implementation:
class PotatoRepositoryImpl : PotatoRepository {
override suspend fun getPotatoesData(): PotatoesData {
var potatoes = PotatoesData("TEST", emptyList())
FirestoreProvider.getLastPotatoes(
{ potatoesData ->
if (potatoesData != null) {
potatoes = potatoesData
}
},
{
potatoes
}
)
return potatoes
}
}
Firestore provider:
object FirestoreProvider {
private val incidentsRef = FirebaseFirestore.getInstance().collection(FirestoreCollection.POTATOES.key)
fun getLastPotatoes(
success: (potatoesData: PotatoesData?) -> Unit,
failure: () -> Unit
) {
val query: Query = orderBy(FirestoreField.CREATED_DATE, Query.Direction.DESCENDING).limit(1)
val querySnapshot: Task<QuerySnapshot> = query.get()
querySnapshot
.addOnSuccessListener {
if (!querySnapshot.result.isEmpty) {
val document = querySnapshot.result.documents[0]
val potatoesDataDB: PotatoesDataDto? = document.toObject(PotatoesDataDto::class.java)
potatoesDataDB?.let {
success(potatoesDataDB.toPotatoesData())
} ?: run {
success(null)
}
} else {
success(null)
}
}
.addOnFailureListener {
failure()
}
}
private fun orderBy(field: FirestoreField, direction: Query.Direction): Query {
return incidentsRef.orderBy(field.key, direction)
}
}
I am thankful for any kind of help! Thanks in advance!
I think the error is in the way of how you are handling Firestore callbacks. in FirestoreProvider: the callback will fire later than the function getLastPotatoes returns. Try to make that function suspend and use suspendCoroutine to wait for the callback and return it's result. It will look something like:
suspend fun getLastPotatoes() = suspendCoroutine <PotatoesData?> { continuation ->
val query: Query = orderBy(FirestoreField.CREATED_DATE, Query.Direction.DESCENDING).limit(1)
val querySnapshot: Task<QuerySnapshot> = query.get()
querySnapshot
.addOnSuccessListener {
if (!querySnapshot.result.isEmpty) {
val document = querySnapshot.result.documents[0]
val potatoesDataDB: PotatoesDataDto? = document.toObject(PotatoesDataDto::class.java)
potatoesDataDB?.let {
continuation.resume(potatoesDataDB.toPotatoesData())
} ?: run {
continuation.resume(null)
}
} else {
continuation.resume(null)
}
}
.addOnFailureListener {
continuation.resumeWithException(...)
}
}
suspendCoroutine suspends coroutine in which it executed until we decide to continue by calling appropriate methods - Continuation.resume....
In your PotatoRepositoryImpl:
override suspend fun getPotatoesData(): PotatoesData {
var potatoes = PotatoesData("TEST", emptyList())
try {
val potatoesData = FirestoreProvider.getLastPotatoes()
if (potatoesData != null) {
potatoes = potatoesData
}
} catch (e: Exception) {
// handle Exception
}
return potatoes
}

Will the value of isLoading be always true in the Android Jetpack Compose project?

The following code is from the official Advanced State in Jetpack Compose Codelab.
In the function fun DetailsScreen(), uiState.isLoading -> {...} will be fired when isLoading is true.
I searched all the code in the project, I can only find the code val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) to pass value to isLoading.
Will uiState.isLoading -> {...} always be fired in the project?
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
}
uiState.isLoading -> {
Box(modifier.fillMaxSize()) {
CircularProgressIndicator(
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> { onErrorLoading() }
}
}
#HiltViewModel
class DetailsViewModel #Inject constructor(
private val destinationsRepository: DestinationsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val cityName = savedStateHandle.get<String>(KEY_ARG_DETAILS_CITY_NAME)!!
val cityDetails: Result<ExploreModel>
get() {
val destination = destinationsRepository.getDestination(cityName)
return if (destination != null) {
Result.Success(destination)
} else {
Result.Error(IllegalArgumentException("City doesn't exist"))
}
}
}
sealed class Result<out R> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
}
No
The default value of isLoading is false:
val isLoading: Boolean = false,
So the constructor calls that don't explicitly set it to true (DetailsUiState(throwError = true) and DetailsUiState(throwError = true)) will result in it being false.

Categories

Resources