WatchlistViewModel.kt
package com.louisfn.somovie.feature.home.watchlist
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.louisfn.somovie.core.common.annotation.ApplicationScope
import com.louisfn.somovie.core.common.annotation.DefaultDispatcher
import com.louisfn.somovie.core.common.extension.takeAs
import com.louisfn.somovie.core.logger.Logger
import com.louisfn.somovie.domain.model.Movie
import com.louisfn.somovie.domain.usecase.authentication.AuthenticationInteractor
import com.louisfn.somovie.domain.usecase.watchlist.WatchlistInteractor
import com.louisfn.somovie.feature.home.watchlist.WatchlistUiState.AccountLoggedIn.ContentState
import com.louisfn.somovie.feature.home.watchlist.WatchlistUiState.AccountLoggedIn.LoadNextPageState
import com.louisfn.somovie.ui.common.base.BaseViewModel
import com.louisfn.somovie.ui.common.error.ErrorsDispatcher
import com.louisfn.somovie.ui.common.extension.isError
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
@HiltViewModel
internal class WatchlistViewModel @Inject constructor(
@DefaultDispatcher defaultDispatcher: CoroutineDispatcher,
private val authenticationInteractor: AuthenticationInteractor,
private val watchlistInteractor: WatchlistInteractor,
private val errorsDispatcher: ErrorsDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
) : BaseViewModel<WatchlistAction>(defaultDispatcher) {
private val pagingState = MutableStateFlow<PagingState?>(null)
private val hiddenMovieItemIds = MutableStateFlow(emptyList<Long>())
private val pendingSwipedMovieItemIds = CopyOnWriteArrayList<Long>()
//region UiState
private val accountLoggedInUiState: Flow<WatchlistUiState.AccountLoggedIn> =
pagingState
.map(::createUiState)
private val accountDisconnectedUiState: Flow<WatchlistUiState.AccountDisconnected> =
flowOf(WatchlistUiState.AccountDisconnected)
val uiState: StateFlow<WatchlistUiState> =
authenticationInteractor.isLoggedIn()
.flatMapLatest {
if (it) {
accountLoggedInUiState
} else {
accountDisconnectedUiState
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = WatchlistUiState.None,
)
//endregion
//region Paged movies
private val pagedMovies =
watchlistInteractor.watchlistPagingChanges()
.cachedIn(viewModelScope)
val pagedMovieItems =
authenticationInteractor.isLoggedIn()
.filter { it }
.flatMapLatest {
combine(
pagedMovies,
hiddenMovieItemIds,
) { paged, hiddenItemIds ->
paged.map { movie ->
MovieItem(movie = movie, isHidden = movie.id in hiddenItemIds)
}
}
.flowOn(defaultDispatcher)
}
//endregion
@AnyThread
fun onViewHidden() {
removeAllPendingSwipedMovieFromWatchlist()
}
@AnyThread
private fun createUiState(pagingState: PagingState?): WatchlistUiState.AccountLoggedIn {
if (pagingState == null) {
return WatchlistUiState.AccountLoggedIn()
}
val refreshLoadState = pagingState.loadStates.refresh
val appendLoadState = pagingState.loadStates.append
return WatchlistUiState.AccountLoggedIn(
contentState = when {
pagingState.itemCount == 0 && refreshLoadState.isError -> ContentState.RETRY
pagingState.itemCount == 0 -> ContentState.LOADING
else -> ContentState.SUCCESS
},
loadNextPageState = when (appendLoadState) {
is LoadState.Error -> LoadNextPageState.RETRY
is LoadState.Loading -> LoadNextPageState.LOADING
is LoadState.NotLoading -> LoadNextPageState.IDLE
},
)
}
//region Paging state
@AnyThread
fun onLoadStateChanged(loadStates: CombinedLoadStates, itemCount: Int) {
pagingState.value = PagingState(loadStates, itemCount)
loadStates.refresh.takeAs<LoadState.Error>()?.let {
onPagingError(it.error)
}
loadStates.append.takeAs<LoadState.Error>()?.let {
onPagingError(it.error)
}
}
@AnyThread
fun onPagingError(e: Throwable) {
errorsDispatcher.dispatch(e)
}
//endregion
//region Remove from watchlist
@AnyThread
fun onSwipeToDismiss(movie: Movie) {
viewModelScope.launch(defaultDispatcher) {
addAsHidden(movie.id)
addAsPendingSwiped(movie.id)
emitAction(WatchlistAction.ShowUndoSwipeToDismissSnackbar(movie.id))
}
}
@AnyThread
fun onUndoSwipeToDismissSnackbarDismissed(movieId: Long) {
viewModelScope.launch(defaultDispatcher) {
removeAsPendingSwiped(movieId)
removeFromWatchlist(movieId)
}
}
@AnyThread
fun onUndoSwipeToDismissSnackbarActionPerformed(movieId: Long) {
viewModelScope.launch(defaultDispatcher) {
removeAsHidden(movieId)
removeAsPendingSwiped(movieId)
}
}
@AnyThread
private fun removeAllPendingSwipedMovieFromWatchlist() {
applicationScope.launch(defaultDispatcher) {
pendingSwipedMovieItemIds
.forEach {
launch {
try {
removeFromWatchlistWithTimeout(it)
} catch (e: Exception) {
Logger.e(e)
}
}
}
pendingSwipedMovieItemIds.clear()
}
}
@WorkerThread
private suspend fun removeFromWatchlist(movieId: Long) {
try {
withContext(NonCancellable) {
removeFromWatchlistWithTimeout(movieId)
}
} catch (e: Exception) {
removeAsHidden(movieId)
errorsDispatcher.dispatch(e)
}
}
@AnyThread
private suspend fun removeFromWatchlistWithTimeout(movieId: Long) {
withTimeout(REMOVE_FROM_WATCHLIST_TIMEOUT) {
watchlistInteractor.removeFromWatchlist(movieId)
}
}
@WorkerThread
private fun addAsHidden(movieId: Long) {
hiddenMovieItemIds.update { it + movieId }
}
@WorkerThread
private fun removeAsHidden(movieId: Long) {
hiddenMovieItemIds.update { ids -> ids.filter { it != movieId } }
}
@WorkerThread
private fun addAsPendingSwiped(movieId: Long) {
pendingSwipedMovieItemIds.add(movieId)
}
@WorkerThread
private fun removeAsPendingSwiped(movieId: Long) {
pendingSwipedMovieItemIds.removeIf { it == movieId }
}
//endregion
companion object {
private const val REMOVE_FROM_WATCHLIST_TIMEOUT = 10_000L
}
data class PagingState(
val loadStates: CombinedLoadStates,
val itemCount: Int,
)
}