Android Compose with single event - android

Right now I have an Event class in the ViewModel that is exposed as a Flow this way:
abstract class BaseViewModel() : ViewModel() {
...
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val eventsFlow = eventChannel.receiveAsFlow()
fun sendEvent(event: Event) {
viewModelScope.launch {
eventChannel.send(event)
}
}
sealed class Event {
data class NavigateTo(val destination: Int): Event()
data class ShowSnackbarResource(val resource: Int): Event()
data class ShowSnackbarString(val message: String): Event()
}
}
And this is the composable managing it:
#Composable
fun SearchScreen(
viewModel: SearchViewModel
) {
val events = viewModel.eventsFlow.collectAsState(initial = null)
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
) {
Column(
modifier = Modifier
.padding(all = 24.dp)
) {
SearchHeader(viewModel = viewModel)
SearchContent(
viewModel = viewModel,
modifier = Modifier.padding(top = 24.dp)
)
when(events.value) {
is NavigateTo -> TODO()
is ShowSnackbarResource -> {
val resources = LocalContext.current.resources
val message = (events.value as ShowSnackbarResource).resource
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
}
is ShowSnackbarString -> {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = (events.value as ShowSnackbarString).message
)
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
I followed the pattern for single events with Flow from here.
My problem is, the event is handled correctly only the first time (SnackBar is shown correctly). But after that, seems like the events are not collected anymore. At least until I leave the screen and come back. And in that case, all events are triggered consecutively.
Can't see what I'm doing wrong. When debugged, events are sent to the Channel correctly, but seems like the state value is not updated in the composable.

Rather than placing your logic right inside composable place them inside
// Runs only on initial composition
LaunchedEffect(key1 = Unit) {
viewModel.eventsFlow.collectLatest { value ->
when(value) {
// Handle events
}
}
}
And also rather than using it as state just collect value from flow in LaunchedEffect block. This is how I implemented single event in my application

Here's a modified version of Jack's answer, as an extension function following new guidelines for safer flow collection.
#Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
noinline action: suspend (T) -> Unit
) {
LaunchedEffect(key1 = Unit) {
lifecycleOwner.lifecycleScope.launch {
flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}
}
}
Usage:
viewModel.flow.observeWithLifecycle { value ->
//Use the collected value
}

I'm not sure how you manage to compile the code, because I get an error on launch.
Calls to launch should happen inside a LaunchedEffect and not composition
Usually you can use LaunchedEffect which is already running in the coroutine scope, so you don't need coroutineScope.launch. Read more about side effects in documentation.
A little kotlin advice: when using when in types, you don't need to manually cast the variable to a type with as. In cases like this, you can declare val along with your variable to prevent Smart cast to ... is impossible, because ... is a property that has open or custom getter error:
val resources = LocalContext.current.resources
val event = events.value // allow Smart cast
LaunchedEffect(event) {
when (event) {
is BaseViewModel.Event.NavigateTo -> TODO()
is BaseViewModel.Event.ShowSnackbarResource -> {
val message = event.resource
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
is BaseViewModel.Event.ShowSnackbarString -> {
snackbarHostState.showSnackbar(
message = event.message
)
}
}
}
This code has one problem: if you send the same event many times, it will not be shown because LaunchedEffect will not be restarted: event as key is the same.
You can solve this problem in different ways. Here are some of them:
Replace data class with class: now events will be compared by pointer, not by fields.
Add a random id to the data class, so that each new element is not equal to another:
data class ShowSnackbarResource(val resource: Int, val id: UUID = UUID.randomUUID()) : Event()
Note that the coroutine LaunchedEffect will be canceled when a new event occurs. And since showSnackbar is a suspend function, the previous snackbar will be hidden to display the new one. If you run showSnackbar on coroutineScope.launch (still doing it inside LaunchedEffect), the new snackbar will wait until the previous snackbar disappears before it appears.
Another option, which seems cleaner to me, is to reset the state of the event because you have already reacted to it. You can add another event to do this:
object Clean : Event()
And send it after the snackbar disappears:
LaunchedEffect(event) {
when (event) {
is BaseViewModel.Event.NavigateTo -> TODO()
is BaseViewModel.Event.ShowSnackbarResource -> {
val message = event.resource
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
is BaseViewModel.Event.ShowSnackbarString -> {
snackbarHostState.showSnackbar(
message = event.message
)
}
null, BaseViewModel.Event.Clean -> return#LaunchedEffect
}
viewModel.sendEvent(BaseViewModel.Event.Clean)
}
But in this case, if you send the same event while the previous one has not yet disappeared, it will be ignored as before. This can be perfectly normal, depending on the structure of your application, but to prevent this you can show it on coroutineScope as before.
Also, check out the more general solution implemented in the JetNews compose app example. I suggest you download the project and inspect it starting from location where the snackbar is displayed.

https://github.com/Kotlin-Android-Open-Source/Jetpack-Compose-MVI-Coroutines-Flow/blob/master/core-ui/src/main/java/com/hoc/flowmvi/core_ui/rememberFlowWithLifecycle.kt
#Suppress("ComposableNaming")
#Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
vararg keys: Any?,
lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend CoroutineScope.(T) -> Unit
) {
val flow = this
val currentCollector by rememberUpdatedState(collector)
LaunchedEffect(flow, lifecycle, minActiveState, *keys) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(minActiveState) {
flow.collect { currentCollector(it) }
}
}
}
}
class ViewModel {
val singleEvent: Flow<E> = eventChannel.receiveAsFlow()
}
#Composable fun Demo() {
val snackbarHostState by rememberUpdatedState(LocalSnackbarHostState.current)
val scope = rememberCoroutineScope()
viewModel.singleEvent.collectInLaunchedEffectWithLifecycle { event ->
when (event) {
SingleEvent.Refresh.Success -> {
scope.launch {
snackbarHostState.showSnackbar("Refresh successfully")
}
}
is SingleEvent.Refresh.Failure -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to refresh")
}
}
is SingleEvent.GetUsersError -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to get users")
}
}
is SingleEvent.RemoveUser.Success -> {
scope.launch {
snackbarHostState.showSnackbar("Removed '${event.user.fullName}'")
}
}
is SingleEvent.RemoveUser.Failure -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to remove '${event.user.fullName}'")
}
}
}
}
}

Here's a modified version of Soroush Lotfi answer making sure we also stop flow collection whenever the composable is not visible anymore: just replace the LaunchedEffect with a DisposableEffect
#Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
noinline action: suspend (T) -> Unit
) {
DisposableEffect(Unit) {
val job = lifecycleOwner.lifecycleScope.launch {
flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}
onDispose {
job.cancel()
}
}
}

Related

Refresh Data in ViewModel when Navigating back - Android(Kotlin)

I have the following setup.
I have a screen with a list of items (PlantsScreen). When clicking on an item from the list I will be navigated to another screen (AddEditPlantScreen). After editing and saving the item and navigating back to the listScreen, I want to show the updated list of items. But the list is not displaying the updated list but the list before the edit of the item.
In order to have a single source of truth, I am fetching the data from a node.js Back-End and then saving it to the local repository (Room). I think I need to refresh the state in the ViewModel to fetch the updated list from my repository.
I know I can use a Job to do this, but it throws me an error. Is this the correct approach when returning a Flow?
If yes, how can I achieve this.
If not, what alternative approach do I have?
plantsListViewModel.kt
private val _state = mutableStateOf<PlantsState>(PlantsState())
val state: State<PlantsState> = _state
init {
getPlants(true, "")
}
private fun getPlants(fetchFromBackend: Boolean, query: String) {
viewModelScope.launch {
plantRepository.getPlants(fetchFromBackend, query)
.collect { result ->
when (result) {
is Resource.Success -> {
result.data?.let { plants ->
_state.value = state.value.copy(
plants = plants,
)
}
}
}
}
}
}
Here is my repository where I fetch the items in the list from.
// plantsRepository.kt
override suspend fun getPlants(
fetchFromBackend: Boolean,
query: String
): Flow<Resource<List<Plant>>> {
return flow {
emit(Resource.Loading(true))
val localPlants = dao.searchPlants(query)
emit(
Resource.Success(
data = localPlants.map { it.toPlant() },
)
)
val isDbEmpty = localPlants.isEmpty() && query.isBlank()
val shouldLoadFromCache = !isDbEmpty && !fetchFromBackend
if (shouldLoadFromCache) {
emit(Resource.Loading(false))
return#flow
}
val response = plantApi.getPlants().plants
dao.clearPlants()
dao.insertPlants(
response.map { it.toPlantEntity() }
)
emit(Resource.Success(
data = dao.searchPlants("").map { it.toPlant() }
))
emit(Resource.Loading(false))
}
}
The full code for reference can be found here:
https://gitlab.com/fiehra/plants
Thank you!
You actually have two sources of truth: One is the room database, the other the _state object in the view model.
To reduce this to a single source of truth you need to move the collection of the flow to the compose function where the data is needed. You will do this using the extension function StateFlow.collectAsStateWithLifecycle() from the artifact androidx.lifecycle:lifecycle-runtime-compose. This will automatically subscribe and unsubscribe the flow when your composable enters and leaves the composition.
Since you want the business logic to stay in the view model you have to apply it before the flow is collected. The idea is to only transform the flow in the view model:
class PlantsViewModel {
private var fetchFromBackend: Boolean by mutableStateOf(true)
private var query: String by mutableStateOf("")
#OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<PlantsState> =
snapshotFlow { fetchFromBackend to query }
.flatMapLatest { plantRepository.getPlants(it.first, it.second) }
.mapLatest(PlantsState::of)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PlantsState.Loading,
)
// ...
}
If you want other values for fetchFromBackend and query you just need to update the variables; the flow will automatically recalculate the state object. It can be as simple as just calling something like this:
fun requestPlant(fetchFromBackend: Boolean, query: String) {
this.fetchFromBackend = fetchFromBackend
this.query = query
}
The logic to create a PlantsState from a result can then be done somewhere else in the view model. Replace your PlantsViewModel.getPlants() with this and place it at file level outside of the PlantsViewModel class:
private fun PlantsState.Companion.of(result: Resource<List<Plant>>): PlantsState = when (result) {
is Resource.Success -> {
result.data?.let { plants ->
PlantsState.Success(
plants = plants,
)
} ?: TODO("handle case where result.data is null")
}
is Resource.Error -> {
PlantsState.Error("an error occurred")
}
is Resource.Loading -> {
PlantsState.Loading
}
}
With the PlantsState class replaced by this:
sealed interface PlantsState {
object Loading : PlantsState
data class Success(
val plants: List<Plant> = emptyList(),
val plantOrder: PlantOrder = PlantOrder.Name(OrderType.Descending),
val isOrderSectionVisible: Boolean = false,
) : PlantsState
data class Error(
val error: String,
) : PlantsState
companion object
}
Then, wherever you need the state (in PlantsScreen f.e.), you can get a state object with
val state by viewModel.state.collectAsStateWithLifecycle()
Thanks to kotlin flows state will always contain the most current data from the room database, and thanks to the compose magic your composables will always update when anything in the state object updates, so that you really only have one single source of truth.
Additionally:
PlantRepository.getPlants() should not be marked as a suspend function because it just creates a flow and won't block; long running data retrieval will be done in the collector.
You will need to manually import androidx.compose.runtime.getValue and the androidx.compose.runtime.setValue for some of the delegates to work.
After #Leviathan was able to point me in the right direction i refactored my code by changing the return types of my repository functions, implementing use cases and returning a Flow<List<Plant>> instead of Flow<Resource<List<Plant>>> for simplicity purposes.
Further removed the suspend marker of the functions in the PlantDao.kt and PlantRepository.kt as pointed out by Leviathan.
// PlantRepositoryImplementation.kt
override fun getPlants(
fetchFromBackend: Boolean,
query: String
): Flow<List<Plant>> {
return flow {
if (fetchFromBackend) {
val response = plantApi.getPlants().plants
dao.clearPlants()
dao.insertPlants(
response.map { it.toPlantEntity() }
)
val localPlants = dao.searchPlants(query)
localPlants.collect { plants ->
emit(plants.map { it.toPlant() })
return#collect
}
} else {
val localPlants = dao.searchPlants(query)
localPlants.collect { plants ->
emit(plants.map { it.toPlant() })
return#collect
}
}
}
}
I started using a Job and GetPlants usecase in my viewModel like this:
// PlantsViewModel.kt
private fun getPlants(plantOrder: PlantOrder, fetchFromBackend: Boolean, query: String) {
getPlantsJob?.cancel()
getPlantsJob = plantUseCases.getPlants(plantOrder, fetchFromBackend, query)
.onEach { plants ->
_state.value = state.value.copy(
plants = plants,
plantOrder = plantOrder
)
}.launchIn(viewModelScope)
I also had to remove the suspend in the PlantDao.kt
// PlantDao.kt
fun searchPlants(query: String): Flow<List<PlantEntity>>
This is the code for my GetPlants usecase:
// GetPlantsUsecase.kt
class GetPlants
(
private val repository: PlantRepository,
) {
operator fun invoke(
plantOrder: PlantOrder = PlantOrder.Name(OrderType.Descending),
fetchFromBackend: Boolean,
query: String
): Flow<List<Plant>> {
return repository.getPlants(fetchFromBackend, query).map { plants ->
when (plantOrder.orderType) {
is OrderType.Ascending -> {
// logic for sorting
}
}
is OrderType.Descending -> {
// logic for sorting
}
}
}
}
}

Navigate in compose based on result

I've this screen:
#Composable
fun SetNewPasswordScreen(
viewModel: SetNewPasswordViewModel,
navigateToSetDetails: () -> Unit,
) {
val state by viewModel.state.collectAsState()
if (state.passwordConfirmed) {
LaunchedEffect(Unit) {
navigateToSetDetails()
}
} else {
SetNewPasswordScreen(
confirmNewPassword = viewModel::confirmNewPassword
)
}
}
and this is the view model:
#HiltViewModel
class SetNewPasswordViewModel #Inject constructor(
private val setNewPassword: SetNewPassword,
) : ViewModel() {
val state: StateFlow<SetNewPasswordViewState> = setNewPassword.flow
.map { hasSetNewPassword ->
SetNewPasswordViewState(passwordConfirmed = hasSetNewPassword)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = SetNewPasswordViewState.Empty
)
fun confirmNewPassword(newPassword: String, repeatedNewPassword: String) {
viewModelScope.launch {
setNewPassword(SetNewPassword.Params(newPassword, repeatedNewPassword))
}
}
}
My problem is when I try to navigate back from
SetDetailsScreen
to
SetNewPasswordScreen
I'm getting true for state.passwordConfirmed again, and I'm stuck in a loop.
I've thought about resetting passwordConfirmed right after the if statement, or using previousBackStackEntry in SetDetailsScreen to store that password should be confirmed again, but it doesn't seem right to do so every time I want to navigate back to a screen that got a result. What's the best practice for this kind of situation?

Stop collecting Flow in ViewModel when app in background

Need to collect flow in ViewModel and after some data modification, the UI is updated using _batteryProfileState.
Inside compose I'm collecting states like this
val batteryProfile by viewModel.batteryProfileState.collectAsStateWithLifecycle()
batteryProfile.voltage
In ViewModel:
private val _batteryProfileState = MutableStateFlow(BatteryProfileState())
val batteryProfileState = _batteryProfileState.asStateFlow()
private fun getBatteryProfileData() {
viewModelScope.launch {
// FIXME In viewModel we should not collect it like this
_batteryProfile(Unit).collect { result ->
_batteryProfileState.update { state ->
when(result) {
is Result.Success -> {
state.copy(
voltage = result.data.voltage?.toString()
?.plus(result.data.voltageUnit
)
}
is Result.Error -> {
state.copy(
errorMessage = _context.getString(R.string.something_went_wrong)
)
}
}
}
}
}
}
The problem is when I put my app in the background the _batteryProfile(Unit).collect does not stop collecting while in UI batteryProfile.voltage stop updating UI which is correct behavior as I have used collectAsStateWithLifecycle() for UI.
But I have no idea how to achieve the same behavior for ViewModel.
In ViewModel I have used stateIn operator and access data like below everything working fine now:
val batteryProfileState = _batteryProfile(Unit).map { result ->
when(result) {
is Result.Success -> {
BatteryProfileState(
voltage = result.data.voltage?.toString()
?.plus(result.data.voltageUnit.unit)
?: _context.getString(R.string.msg_unknown),
)
}
is Result.Error -> {
BatteryProfileState(
errorMessage = _context.getString(R.string.something_went_wrong)
)
}
}
}.stateIn(viewModelScope, WhileViewSubscribed, BatteryProfileState())
collecting data in composing will be the same
Explanation: WhileViewSubscribed Stops updating data while the app is in the background for more than 5 seconds.
val WhileViewSubscribed = SharingStarted.WhileSubscribed(5000)
You can try to define getBatteryProfileData() as suspend fun:
suspend fun getBatteryProfileData() {
// FIXME In viewModel we should not collect it like this
_batteryProfile(Unit).collect { result ->
_batteryProfileState.update { state ->
when(result) {
is Result.Success -> {
state.copy(
voltage = result.data.voltage?.toString()
?.plus(result.data.voltageUnit
)
}
is Result.Error -> {
state.copy(
errorMessage = _context.getString(R.string.something_went_wrong)
)
}
}
}
}
}
And than in your composable define scope:
scope = rememberCoroutineScope()
scope.launch {
yourviewmodel.getBatteryProfileData()
}
And I think you can move suspend fun getBatteryProfileData() out of ViewModel class...

State and MutableStateof dosnt observe data when it changes or force recomposition Like LiveData

I can't figure out how states work in jetpack compose, as I read, whenever the value changes in a State or mutablestate changes it's supposed to force recomposition and change the view, but that doesn't happen in my case
Here
I am posting data to server and I receive the response successfully in my log
#HiltViewModel
class LoginViewModel #Inject constructor(
private val loginUseCase: CheckPhoneUseCase
) : ViewModel() {
private val _state = mutableStateOf(ResponseState())
val state : State<ResponseState> = _state
fun checkPhone(phone: String) : Boolean{
val body = PhoneBodyModel(phone.trim())
loginUseCase.checkPhone(body).onEach { response ->
when (response) {
is Resource.Success -> {
_state.value = ResponseState(isSuccess = response.data?.status ?: false)
Log.v("LoginViewModel", "checkPhone: ${_state.value.isSuccess}")
}
is Resource.Error -> {
_state.value = ResponseState(isError = response.message ?: "UnExpected Error")
Log.v("LoginViewModel", "checkPhone: ${response.message}")
}
is Resource.Loading -> {
_state.value = ResponseState(isLoading = true)
}
}
}.launchIn(viewModelScope)
return _state.value.isSuccess
}
and in my Compose Screen
if (!phoneOrEmail.isEmpty()){
viewModel.checkPhone(phoneOrEmail)
Log.v("viewModel.state", viewModel.state.value.isSuccess.toString())
if (viewModel.state.value.isSuccess){
Log.v("viewModel.state", viewModel.state.value.isSuccess.toString())
navController.navigate(route = Screen.OTPScreen.withArgs(phoneOrEmail))
}
// Toast.makeText(context, "Phone number is not registered", Toast.LENGTH_LONG).show()
}
UADProgressBare(isDisplayed = viewModel.state.value.isLoading,
modifier = Modifier.padding(24.dp))
and through logging, I can see that the data is received, yet in my Compose Screen the data doesn't change at all from its initial value and doesn't force recomposition, as I read and watched online, that State and MutablkeState suppose to force recomposition and receive the new value whenever it changes just like LiveData, yet it doesn't work as LiveData for me, I don't know what is my mistake here so I can force recomposition whenever the value changes.
Maybe you should use this way:
#Composable
fun ComposeScreen() {
//initiate your check phone or email
...
LaunchedEffect(Unit) {
snapshotFlow { viewModel.state.value }
.collect {
if (it.isSuccess) ... else ...
}
}
}
or
#Composable
fun ComposeScreen() {
//initiate your check phone or email
...
LaunchedEffect(viewModel.state.value) {
if (viewModel.state.value.isSuccess){ ... }
}

How to schedule an API request asynchronously for one composable screen from another composable screen? (Jetpack Compose)

I'm a junior Android developer and trying to build a Facebook-like social media app. My issue is that when I bookmark a post in Screen B and the action succeeds, (1) I want to launch an API request in Screen A while in Screen B and (2) update the bookmarked icon ONLY for that particular post.
For the second part of the issue, I tried these two solutions.
I relaunched a manual API request on navigating back to Screen A. This updates the whole list when there's only one small change, hence very inefficient.
I built another URL route to fetch that updated post only and launched it on navigating back to Screen A. But to insert the newly updated post at the old index, the list has to be mutable and I ain't sure this is a good practice.
Please help me on how to solve this issue or similar issues. I'm not sure if this should be done by passing NavArg to update locally and then some or by using web sockets. Thanks in advance.
data class ScreenAState(
val posts: List<Post> = emptyList(),
val isLoading: Boolean = false)
data class ScreenBState(
val post: PostDetail? = null,
val isBookmarked: Boolean? = null)
data class Post(
val title: String,
val isBookMarked: Boolean,
val imageUrl: String)
data class PostDetail(
val title: String,
val content: String,
val isBookMarked: Boolean,
val imageUrl: String)
I suggest you continue with using your logic that will update your list on return from screen B to screen A, but instead of using simple list, you could use:
https://developer.android.com/reference/kotlin/androidx/compose/runtime/snapshots/SnapshotStateList
This list is designed for what you need I think. Update just that one element.
In mean time, you can change that item from list to some loading dummy item, if you want to have loading like view while you wait for API call to finish.
The problem is how to handle data consistency, which is not directly related to jetpack compose. I suggest you solve this problem at the model level. Return flow instead of static data in the repository, and use collectAsState in the jetpack compose to monitor data changes.
It's hard to give an example, because it depends on the type of Model layer. If it's a database, androidx's room library supports returning flow; if it's a network, take a look at this.
https://gist.github.com/FishHawk/6e4706646401bea20242bdfad5d86a9e
Triggering a refresh is not a good option. It is better to maintain an ActionChannel in the repository for each list that is monitored. use the ActionChannel to modify the list locally to notify compose of the update.
For example, you can make a PagedList if the data layer is network. With onStart and onClose, channels can be added or removed from the repository, thus giving the repository the ability to update all the observed lists.
sealed interface RemoteListAction<out T> {
data class Mutate<T>(val transformer: (MutableList<T>) -> MutableList<T>) : RemoteListAction<T>
object Reload : RemoteListAction<Nothing>
object RequestNextPage : RemoteListAction<Nothing>
}
typealias RemoteListActionChannel<T> = Channel<RemoteListAction<T>>
suspend fun <T> RemoteListActionChannel<T>.mutate(transformer: (MutableList<T>) -> MutableList<T>) {
send(RemoteListAction.Mutate(transformer))
}
suspend fun <T> RemoteListActionChannel<T>.reload() {
send(RemoteListAction.Reload)
}
suspend fun <T> RemoteListActionChannel<T>.requestNextPage() {
send(RemoteListAction.RequestNextPage)
}
class RemoteList<T>(
private val actionChannel: RemoteListActionChannel<T>,
val value: Result<PagedList<T>>?,
) {
suspend fun mutate(transformer: (MutableList<T>) -> MutableList<T>) =
actionChannel.mutate(transformer)
suspend fun reload() = actionChannel.reload()
suspend fun requestNextPage() = actionChannel.requestNextPage()
}
data class PagedList<T>(
val list: List<T>,
val appendState: Result<Unit>?,
)
data class Page<Key : Any, T>(
val data: List<T>,
val nextKey: Key?,
)
fun <Key : Any, T> remotePagingList(
startKey: Key,
loader: suspend (Key) -> Result<Page<Key, T>>,
onStart: ((actionChannel: RemoteListActionChannel<T>) -> Unit)? = null,
onClose: ((actionChannel: RemoteListActionChannel<T>) -> Unit)? = null,
): Flow<RemoteList<T>> = callbackFlow {
val dispatcher = Dispatchers.IO.limitedParallelism(1)
val actionChannel = Channel<RemoteListAction<T>>()
var listState: Result<Unit>? = null
var appendState: Result<Unit>? = null
var value: MutableList<T> = mutableListOf()
var nextKey: Key? = startKey
onStart?.invoke(actionChannel)
suspend fun mySend() {
send(
RemoteList(
actionChannel = actionChannel,
value = listState?.map {
PagedList(
appendState = appendState,
list = value,
)
},
)
)
}
fun requestNextPage() = launch(dispatcher) {
nextKey?.let { key ->
appendState = null
mySend()
loader(key)
.onSuccess {
value.addAll(it.data)
nextKey = it.nextKey
listState = Result.success(Unit)
appendState = Result.success(Unit)
mySend()
}
.onFailure {
if (listState?.isSuccess != true)
listState = Result.failure(it)
appendState = Result.failure(it)
mySend()
}
}
}
var job = requestNextPage()
launch(dispatcher) {
actionChannel.receiveAsFlow().flowOn(dispatcher).collect { action ->
when (action) {
is RemoteListAction.Mutate -> {
value = action.transformer(value)
mySend()
}
is RemoteListAction.Reload -> {
job.cancel()
listState = null
appendState = null
value.clear()
nextKey = startKey
mySend()
job = requestNextPage()
}
is RemoteListAction.RequestNextPage -> {
if (!job.isActive) job = requestNextPage()
}
}
}
}
launch(dispatcher) {
Connectivity.instance?.interfaceName?.collect {
if (job.isActive) {
job.cancel()
job = requestNextPage()
}
}
}
awaitClose {
onClose?.invoke(actionChannel)
}
}
And in repository:
val postListActionChannels = mutableListOf<RemoteListActionChannel<Post>>()
suspend fun listPost() =
daoFlow.filterNotNull().flatMapLatest {
remotePagingList(
startKey = 0,
loader = { page ->
it.mapCatching { dao ->
/* dao function, simulate network operation, return List<Post> */
dao.listPost(page)
}.map { Page(it, if (it.isEmpty()) null else page + 1) }
},
onStart = { postListActionChannels.add(it) },
onClose = { postListActionChannels.remove(it) },
)
}
suspend fun markPost(title: String) =
oneshot {
/* dao function, simulate network operation, return Unit */
it.markPost(title)
}.onSuccess {
postListActionChannels.forEach { ch ->
ch.mutate { list ->
list.map {
if (it.title == title && !it.isBookMarked)
it.copy(isBookMarked = true)
else it
}.toMutableList()
}
}
}

Categories

Resources