DiscoverViewModel.kt
package com.louisfn.somovie.feature.home.discover
import androidx.annotation.AnyThread
import androidx.lifecycle.viewModelScope
import com.louisfn.somovie.core.common.Result
import com.louisfn.somovie.core.common.annotation.DefaultDispatcher
import com.louisfn.somovie.core.common.asFlowResult
import com.louisfn.somovie.core.common.data
import com.louisfn.somovie.core.common.extension.safeCollect
import com.louisfn.somovie.core.common.isError
import com.louisfn.somovie.core.common.isLoading
import com.louisfn.somovie.core.common.onResultError
import com.louisfn.somovie.domain.model.Movie
import com.louisfn.somovie.domain.usecase.authentication.AuthenticationInteractor
import com.louisfn.somovie.domain.usecase.movie.MovieDiscoverInteractor
import com.louisfn.somovie.domain.usecase.watchlist.WatchlistInteractor
import com.louisfn.somovie.feature.home.discover.DiscoverUiState.Discover.LogInSnackbarState
import com.louisfn.somovie.feature.home.discover.DiscoverUiState.MovieItem
import com.louisfn.somovie.ui.common.base.BaseViewModel
import com.louisfn.somovie.ui.common.base.NoneAction
import com.louisfn.somovie.ui.common.error.ErrorsDispatcher
import com.louisfn.somovie.ui.common.model.ImmutableList
import com.louisfn.somovie.ui.component.swipe.SwipeDirection
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
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.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class DiscoverViewModel @Inject constructor(
@DefaultDispatcher defaultDispatcher: CoroutineDispatcher,
private val movieDiscoverInteractor: MovieDiscoverInteractor,
private val watchlistInteractor: WatchlistInteractor,
private val authenticationInteractor: AuthenticationInteractor,
private val movieItemMapper: DiscoverMovieItemMapper,
private val errorsDispatcher: ErrorsDispatcher,
) : BaseViewModel<NoneAction>(defaultDispatcher) {
private val moviesState = MutableStateFlow(emptyList<Movie>())
private val fetchNewMoviesResultState = MutableStateFlow<Result<List<Movie>>?>(null)
private val isLogInSnackbarShaking = MutableStateFlow(false)
private val isLoggedIn: Flow<Boolean> =
authenticationInteractor.isLoggedIn()
.stateIn(viewModelScope, SharingStarted.Lazily, null)
.filterNotNull()
val uiState: StateFlow<DiscoverUiState> =
combine(
moviesState.map(movieItemMapper::map),
fetchNewMoviesResultState,
isLoggedIn,
isLogInSnackbarShaking,
) { items, fetchNewMoviesResultState, isLoggedIn, isLogInSnackbarShaking ->
createDiscoverUiState(
items = items,
fetchMovieResultState = fetchNewMoviesResultState,
isLoggedIn = isLoggedIn,
isLogInSnackbarShaking = isLogInSnackbarShaking,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = DiscoverUiState.None,
)
init {
fetchNewDiscoverMovies()
}
@AnyThread
private fun createDiscoverUiState(
items: ImmutableList<MovieItem>,
fetchMovieResultState: Result<List<Movie>>?,
isLoggedIn: Boolean,
isLogInSnackbarShaking: Boolean,
): DiscoverUiState = when {
items.isNotEmpty() -> DiscoverUiState.Discover(
items = items,
logInSnackbarState = when {
isLoggedIn -> LogInSnackbarState.HIDDEN
isLogInSnackbarShaking -> LogInSnackbarState.SHAKING
else -> LogInSnackbarState.VISIBLE
},
)
fetchMovieResultState.isLoading -> DiscoverUiState.Loading
fetchMovieResultState.isError -> DiscoverUiState.Retry
else -> DiscoverUiState.None
}
@AnyThread
fun onMovieSwiped(movieItem: MovieItem, direction: SwipeDirection) {
viewModelScope.launch(defaultDispatcher) {
if (direction.shouldAddMovieToWatchlist()) {
if (!isLoggedIn.first()) {
shakeLogInSnackbar()
return@launch
}
addToWatchlist(movieItem)
}
}
}
@AnyThread
fun onMovieDisappeared(movieItem: MovieItem) {
viewModelScope.launch(defaultDispatcher) {
moviesState.update { movies -> movies.filter { it.id != movieItem.id } }
if (moviesState.value.size <= MIN_MOVIE_COUNT_BEFORE_FETCH) {
fetchNewDiscoverMovies()
}
}
}
@AnyThread
fun retry() {
fetchNewDiscoverMovies()
}
@AnyThread
private fun fetchNewDiscoverMovies() {
viewModelScope.launch(defaultDispatcher) {
val currentState = fetchNewMoviesResultState.getAndUpdate { Result.Loading() }
if (currentState !is Result.Loading) {
asFlowResult { movieDiscoverInteractor.getDiscoverMovies() }
.onResultError(errorsDispatcher::dispatch)
.safeCollect(
onEach = { result ->
moviesState.update { it + result.data.orEmpty() }
fetchNewMoviesResultState.emit(result)
},
onError = errorsDispatcher::dispatch,
)
}
}
}
@AnyThread
private suspend fun addToWatchlist(movieItem: MovieItem) {
try {
watchlistInteractor.addToWatchlist(movieItem.id)
} catch (e: Exception) {
errorsDispatcher.dispatch(e)
}
}
@AnyThread
private suspend fun shakeLogInSnackbar() {
if (isLogInSnackbarShaking.compareAndSet(expect = false, update = true)) {
delay(SHAKE_DELAY)
isLogInSnackbarShaking.value = false
}
}
@AnyThread
private fun SwipeDirection.shouldAddMovieToWatchlist() = this == SwipeDirection.RIGHT
companion object {
private const val MIN_MOVIE_COUNT_BEFORE_FETCH = 5
private const val SHAKE_DELAY = 1000L
}
}