Search code examples
androidkotlinkotlin-flowkotlin-stateflow

stateIn not able to collect the Result.Success state


I am an absolute novice to Kotlin Flow and StateFlow. I cannot figure out why for one of the REST APIs, the Flow does not emit the Success result even though the API succeeds(I can see the HTTP logging interceptor printing a 200 status code with a valid response body).

My app has 2 REST APIs that are getting called in ServiceViewModel:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.domain.usecase.GetServiceTypesUseCase
import com.example.domain.usecase.GetServicesUseCase
import com.example.ui.util.Result
import com.example.ui.util.asResult
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

class ServiceViewModel(
    private val getServicesUseCase: GetServicesUseCase,
    private val getServiceTypesUseCase: GetServiceTypesUseCase,
) : ViewModel() {

    val serviceTypeUiState = getServiceTypesUseCase()
        .asResult()
        .map { it: Result<List<FilterableServiceType>> ->
            // This never receives the Success emission from asResult() call; only Loading
            println("Result state is : $it")
            when (it) {
                is Result.Error -> ServiceTypesUiState.Error(it.exception)
                is Result.Loading -> ServiceTypesUiState.Loading
                is Result.Success -> ServiceTypesUiState.Success(it.data)
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = Result.Loading
        )

    val servicesUiState = getServicesUseCase()
        .asResult()
        .map {
            when (it) {
                is Result.Error -> ServicesUiState.Error(it.exception)
                is Result.Loading -> ServicesUiState.Loading
                is Result.Success -> ServicesUiState.Success(it.data)
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = Result.Loading
        )
}


Result.kt

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Error(val exception: Throwable) : Result<Nothing>
    data object Loading : Result<Nothing>
}

fun <T> Flow<T>.asResult(): Flow<Result<T>> {
    return this
        .map<T, Result<T>> {
            Result.Success(data = it)
        }
        .onStart {
            emit(Result.Loading)
        }
        .catch {
            emit(Result.Error(exception = it))
        }
}

Below are the 2 use cases that fetches the data from domain and repository layers are:

import com.example.domain.ServiceRepository
import com.example.domain.model.Service
import com.example.domain.util.DataStoreManager
import com.example.domain.model.FilterableServiceType
import kotlinx.coroutines.flow.Flow

class GetServicesUseCase(
    private val serviceRepository: ServiceRepository,
    private val dataStoreManager: DataStoreManager,
) {
    operator fun invoke(): Flow<List<Service>> =
        serviceRepository.getServices(dataStoreManager.getUserToken())
}

class GetServiceTypesUseCase(
    private val serviceRepository: ServiceRepository,
    private val dataStoreManager: DataStoreManager,
) {
    operator fun invoke(): Flow<List<FilterableServiceType>> =
        serviceRepository.getServiceTypes(
            tokenFlow = dataStoreManager.getUserToken()
        )
}

ServiceRepositoryImpl.kt:

import com.example.domain.ServiceRepository
import com.example.domain.model.FilterableServiceType
import com.example.domain.model.Service
import com.example.repository.api.model.ServiceType
import com.example.repository.api.model.toResponse
import com.example.repository.api.model.toService
import com.example.repository.util.toBearerToken
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow

class ServiceRepositoryImpl(private val apiDataSource: ApiDataSource) : ServiceRepository {
    override fun getServices(tokenFlow: Flow<String>): Flow<List<Service>> {
        return flow {
            val token = tokenFlow.first().toString().toBearerToken()
            val services = apiDataSource.getServices(token).map {
                it.toService()
            }
            emit(services)
        }
    }

    override fun getServiceTypes(tokenFlow: Flow<String>): Flow<List<FilterableServiceType>> =
        flow {
            val token = tokenFlow.first().toString().toBearerToken()
            apiDataSource.getServiceTypes(token)
                .map(ServiceType::toResponse)
                .map(::FilterableServiceType)
        }
}

ApiDataSourceImpl.kt

class ApiDataSourceImpl(
    private val apiService: ApiService,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ApiDataSource {
       override suspend fun getServices(token: String): List<ServiceModel> =
        withContext(ioDispatcher) {
            apiService.getServices(token = token)
        }

    override suspend fun getServiceTypes(token: String): List<ServiceType> =
        withContext(ioDispatcher) {
            apiService.getServiceTypes(token)
        }
}

Composable UI for ServiceScreen.kt

@Composable
fun ServiceScreen(
    serviceViewModel: ServiceViewModel,
    onServiceClick: (serviceId: String) -> Unit,
) {

    // Collect the UI states from the view model.
    val servicesUiState by serviceViewModel.servicesUiState.collectAsStateWithLifecycle()
    val serviceTypesUiState by serviceViewModel.serviceTypeUiState.collectAsStateWithLifecycle()

    println("serviceTypesUiState: $serviceTypesUiState")

    Column(
        modifier = Modifier.fillMaxSize()
    ) {

        when (val serviceTypesUiStateOb = serviceTypesUiState) {
            is ServiceTypesUiState.Success -> FilterRow(
                serviceTypesUiStateOb.serviceTypeResponses,
                serviceViewModel
            )

            is ServiceTypesUiState.Loading -> {
                CircularProgressBar()
            }

            is ServiceTypesUiState.Error -> {
                Text(text = "Error: ${serviceTypesUiStateOb.exception.localizedMessage}")
            }
        }

        when (val servicesUiStateOb = servicesUiState) {
            is ServicesUiState.Loading -> CircularProgressBar()

            is ServicesUiState.Success -> ServiceList(
                servicesUiStateOb.services,
                onServiceClick = onServiceClick
            )

            is ServicesUiState.Error -> servicesUiStateOb.exception.localizedMessage?.let {
                Text(
                    text = it
                )
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterRow(
    filterableServiceTypes: List<FilterableServiceType>,
    serviceViewModel: ServiceViewModel,
) {
    println("FilterRow: serviceTypesUiState : $filterableServiceTypes")

    var isSelected by remember { mutableStateOf(false) }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        filterableServiceTypes.forEachIndexed { index, filterableServiceType ->
            ElevatedFilterChip(
                onClick = {
                    isSelected = !isSelected
                },
                label = {
                    Text(filterableServiceType.serviceType.type)
                },
                selected = isSelected,
                leadingIcon = when {
                    isSelected -> {
                        {
                            Icon(
                                imageVector = Icons.Filled.Done,
                                contentDescription = "Filter services for ${filterableServiceType.serviceType.type}",
                                modifier = Modifier.size(FilterChipDefaults.IconSize)
                            )
                        }
                    }

                    else -> {
                        null
                    }
                }
            )
        }
    }
}

sealed interface ServicesUiState {
    data object Loading : ServicesUiState
    data class Success(val services: List<Service>) : ServicesUiState
    data class Error(val exception: Throwable) : ServicesUiState
}

sealed interface ServiceTypesUiState {
    data object Loading : ServiceTypesUiState
    data class Success(val serviceTypeResponses: List<FilterableServiceType>) : ServiceTypesUiState
    data class Error(val exception: Throwable) : ServiceTypesUiState
}

Model data classes for Service Types business logic:

data class ServiceType(
    val id: String,
    val type: String,
)

@Parcelize
data class ServiceTypeResponse(
    val id: String,
    val type: String,
) : Parcelable

class FilterableServiceType(
    val serviceType: ServiceTypeResponse,
    initialChecked: Boolean = false,
) {
    var isSelected: Boolean by mutableStateOf(initialChecked)
}

fun ServiceType.toResponse() = ServiceTypeResponse(
    id = id, type = type
)

When I start the app and navigate to the ServiceScreen from the appropriate route using the NavHostController, I can fetch the data for /api/services, which shows the list of Services correctly, but it does not show the data for /api/services/types for FilterRow composable. I am collecting the states using the collectAsStateWithLifecycle inside the ServiceScreen composable.


Solution

  • The main problem was in the flow builder that I use in ServiceRepositoryImpl class. I was calling the API but never emitted anything from this flow builder. The solution is below:

    
    import com.example.domain.ServiceRepository
    import com.example.domain.model.FilterableServiceType
    import com.example.domain.model.Service
    import com.example.repository.api.model.ServiceType
    import com.example.repository.api.model.toResponse
    import com.example.repository.api.model.toService
    import com.example.repository.util.toBearerToken
    import kotlinx.coroutines.flow.Flow
    import kotlinx.coroutines.flow.first
    import kotlinx.coroutines.flow.flow
    
    class ServiceRepositoryImpl(private val apiDataSource: ApiDataSource) : ServiceRepository {
        override fun getServices(tokenFlow: Flow<String>): Flow<List<Service>> {
            return flow {
                val token = tokenFlow.first().toString().toBearerToken()
                val services = apiDataSource.getServices(token).map {
                    it.toService()
                }
                emit(services) // emitted here
            }
        }
    
        override fun getServiceTypes(tokenFlow: Flow<String>): Flow<List<FilterableServiceType>> =
            flow {
                val token = tokenFlow.first().toString().toBearerToken()
                emit( // missing emit call for this flow builder
                    apiDataSource.getServiceTypes(token)
                        .map(ServiceType::toResponse)
                        .map(::FilterableServiceType)
                )
            }
    }