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 Service
s 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.
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)
)
}
}