diff --git a/base/src/main/java/app/bettermetesttask/constants/TaskConstants.kt b/base/src/main/java/app/bettermetesttask/constants/TaskConstants.kt index ec24c08..0bb0803 100644 --- a/base/src/main/java/app/bettermetesttask/constants/TaskConstants.kt +++ b/base/src/main/java/app/bettermetesttask/constants/TaskConstants.kt @@ -10,5 +10,5 @@ enum class TaskVariance { * while COMPOSE means that MoviesComposeFragment is used instead. */ object TaskConstants { - val TASK_VARIANCE = TaskVariance.XML + val TASK_VARIANCE = TaskVariance.COMPOSE } \ No newline at end of file diff --git a/data-movies/src/main/java/app/bettermetesttask/datamovies/database/dao/MoviesDao.kt b/data-movies/src/main/java/app/bettermetesttask/datamovies/database/dao/MoviesDao.kt index a14f5a4..0a2bf38 100644 --- a/data-movies/src/main/java/app/bettermetesttask/datamovies/database/dao/MoviesDao.kt +++ b/data-movies/src/main/java/app/bettermetesttask/datamovies/database/dao/MoviesDao.kt @@ -10,17 +10,23 @@ import app.bettermetesttask.datamovies.database.entities.MovieEntity import kotlinx.coroutines.flow.Flow @Dao -interface MoviesDao{ +interface MoviesDao { @Query("SELECT * FROM MoviesTable") suspend fun selectMovies(): List + @Query("SELECT * FROM MoviesTable") + fun selectMoviesFlow(): Flow> + @Query("SELECT * FROM MoviesTable WHERE id = :id") suspend fun selectMovieById(id: Int): List @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertMovie(movie: MovieEntity) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertMovies(movies: List) + @Update suspend fun updateMovie(movie: MovieEntity) diff --git a/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/MoviesRepositoryImpl.kt b/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/MoviesRepositoryImpl.kt index 164081b..683b96a 100644 --- a/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/MoviesRepositoryImpl.kt +++ b/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/MoviesRepositoryImpl.kt @@ -7,17 +7,23 @@ import app.bettermetesttask.domaincore.utils.Result import app.bettermetesttask.domainmovies.entries.Movie import app.bettermetesttask.domainmovies.repository.MoviesRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import javax.inject.Inject class MoviesRepositoryImpl @Inject constructor( private val localStore: MoviesLocalStore, + private val restStore: MoviesRestStore, private val mapper: MoviesMapper ) : MoviesRepository { - private val restStore = MoviesRestStore() + override suspend fun getMoviesRest(): List { + return restStore.getMovies() + } - override suspend fun getMovies(): Result> { - TODO("Not yet implemented") + override fun getMoviesDAO(): Flow> { + return localStore.getMoviesFlow().map { entity -> + entity.map { mapper.mapFromLocal(it) } + } } override suspend fun getMovie(id: Int): Result { @@ -35,4 +41,8 @@ class MoviesRepositoryImpl @Inject constructor( override suspend fun removeMovieFromFavorites(movieId: Int) { localStore.dislikeMovie(movieId) } + + override suspend fun addMoviesToDao(movies: List) { + localStore.insertMovies(movies.map { mapper.mapToLocal(it) }) + } } \ No newline at end of file diff --git a/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/stores/MoviesLocalStore.kt b/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/stores/MoviesLocalStore.kt index a8e3813..737c76b 100644 --- a/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/stores/MoviesLocalStore.kt +++ b/data-movies/src/main/java/app/bettermetesttask/datamovies/repository/stores/MoviesLocalStore.kt @@ -5,7 +5,6 @@ import app.bettermetesttask.datamovies.database.dao.MoviesDao import app.bettermetesttask.datamovies.database.entities.LikedMovieEntity import app.bettermetesttask.datamovies.database.entities.MovieEntity import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -20,6 +19,14 @@ class MoviesLocalStore @Inject constructor( return moviesDao.selectMovies() } + fun getMoviesFlow(): Flow> { + return moviesDao.selectMoviesFlow() + } + + suspend fun insertMovies(movies: List) { + moviesDao.insertMovies(movies) + } + suspend fun getMovie(id: Int): MovieEntity { return moviesDao.selectMovieById(id).first() } @@ -33,6 +40,7 @@ class MoviesLocalStore @Inject constructor( } fun observeLikedMoviesIds(): Flow> { - return moviesDao.selectLikedEntries().map { movieIdsFlow -> movieIdsFlow.map { it.movieId } } + return moviesDao.selectLikedEntries() + .map { movieIdsFlow -> movieIdsFlow.map { it.movieId } } } } \ No newline at end of file diff --git a/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/AddMoviesToDaoUseCase.kt b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/AddMoviesToDaoUseCase.kt new file mode 100644 index 0000000..3c7565f --- /dev/null +++ b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/AddMoviesToDaoUseCase.kt @@ -0,0 +1,14 @@ +package app.bettermetesttask.domainmovies.interactors + +import app.bettermetesttask.domainmovies.entries.Movie +import app.bettermetesttask.domainmovies.repository.MoviesRepository +import javax.inject.Inject + +class AddMoviesToDaoUseCase @Inject constructor( + private val repository: MoviesRepository +) { + + suspend operator fun invoke(movies: List) { + repository.addMoviesToDao(movies) + } +} \ No newline at end of file diff --git a/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/GetMoviesRestUseCase.kt b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/GetMoviesRestUseCase.kt new file mode 100644 index 0000000..e4ec93d --- /dev/null +++ b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/GetMoviesRestUseCase.kt @@ -0,0 +1,23 @@ +package app.bettermetesttask.domainmovies.interactors + +import app.bettermetesttask.domainmovies.entries.Movie +import app.bettermetesttask.domainmovies.repository.MoviesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class GetMoviesRestUseCase @Inject constructor( + private val repository: MoviesRepository +) { + + operator fun invoke(): Flow> { + return flow { + try { + val result = repository.getMoviesRest() + emit(result) + } catch (e: Exception) { + error(e) + } + } + } +} \ No newline at end of file diff --git a/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/ObserveMoviesUseCase.kt b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/ObserveMoviesUseCase.kt index 1dced6c..331ba37 100644 --- a/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/ObserveMoviesUseCase.kt +++ b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/interactors/ObserveMoviesUseCase.kt @@ -1,35 +1,22 @@ package app.bettermetesttask.domainmovies.interactors -import app.bettermetesttask.domaincore.utils.Result import app.bettermetesttask.domainmovies.entries.Movie import app.bettermetesttask.domainmovies.repository.MoviesRepository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combineTransform import javax.inject.Inject class ObserveMoviesUseCase @Inject constructor( private val repository: MoviesRepository ) { - suspend operator fun invoke(): Flow>> { - return when (val result = repository.getMovies()) { - is Result.Success -> { - repository.observeLikedMovieIds() - .map { likedMoviesIds -> - val movies = result.data.map { - if (likedMoviesIds.contains(it.id)) { - it.copy(liked = true) - } else { - it - } - } - Result.Success(movies) - } + operator fun invoke(): Flow> { + return repository.getMoviesDAO() + .combineTransform(repository.observeLikedMovieIds()) { movies, like -> + val x = movies.map { movie -> + movie.copy(liked = like.contains(movie.id)) + } + emit(x) } - is Result.Error -> { - flowOf(result) - } - } } } \ No newline at end of file diff --git a/domain-movies/src/main/java/app/bettermetesttask/domainmovies/repository/MoviesRepository.kt b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/repository/MoviesRepository.kt index 8e80ed4..0bc6497 100644 --- a/domain-movies/src/main/java/app/bettermetesttask/domainmovies/repository/MoviesRepository.kt +++ b/domain-movies/src/main/java/app/bettermetesttask/domainmovies/repository/MoviesRepository.kt @@ -6,7 +6,9 @@ import kotlinx.coroutines.flow.Flow interface MoviesRepository { - suspend fun getMovies(): Result> + suspend fun getMoviesRest(): List + + fun getMoviesDAO(): Flow> suspend fun getMovie(id: Int): Result @@ -14,5 +16,7 @@ interface MoviesRepository { suspend fun addMovieToFavorites(movieId: Int) + suspend fun addMoviesToDao(movies: List) + suspend fun removeMovieFromFavorites(movieId: Int) } diff --git a/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesState.kt b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesState.kt index deafd37..50bd3e3 100644 --- a/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesState.kt +++ b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesState.kt @@ -8,5 +8,9 @@ sealed class MoviesState { object Loading : MoviesState() - data class Loaded(val movies: List) : MoviesState() + data class Loaded( + val movies: List, + val showInfo: Boolean = false, + val movieInfo: Movie? = null + ) : MoviesState() } \ No newline at end of file diff --git a/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesViewModel.kt b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesViewModel.kt index 44f9e76..94211d0 100644 --- a/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesViewModel.kt +++ b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/MoviesViewModel.kt @@ -1,47 +1,80 @@ package app.bettermetesttask.movies.sections import androidx.lifecycle.ViewModel -import app.bettermetesttask.domaincore.utils.Result +import androidx.lifecycle.viewModelScope import app.bettermetesttask.domainmovies.entries.Movie import app.bettermetesttask.domainmovies.interactors.AddMovieToFavoritesUseCase +import app.bettermetesttask.domainmovies.interactors.AddMoviesToDaoUseCase +import app.bettermetesttask.domainmovies.interactors.GetMoviesRestUseCase import app.bettermetesttask.domainmovies.interactors.ObserveMoviesUseCase import app.bettermetesttask.domainmovies.interactors.RemoveMovieFromFavoritesUseCase -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject class MoviesViewModel @Inject constructor( private val observeMoviesUseCase: ObserveMoviesUseCase, private val likeMovieUseCase: AddMovieToFavoritesUseCase, private val dislikeMovieUseCase: RemoveMovieFromFavoritesUseCase, + private val addMoviesToDaoUseCase: AddMoviesToDaoUseCase, + private val getMoviesRestUseCase: GetMoviesRestUseCase, private val adapter: MoviesAdapter ) : ViewModel() { - private val moviesMutableFlow: MutableStateFlow = MutableStateFlow(MoviesState.Initial) + private val moviesMutableFlow: MutableStateFlow = + MutableStateFlow(MoviesState.Initial) val moviesStateFlow: StateFlow get() = moviesMutableFlow.asStateFlow() fun loadMovies() { - GlobalScope.launch { - observeMoviesUseCase() - .collect { result -> - if (result is Result.Success) { - moviesMutableFlow.emit(MoviesState.Loaded(result.data)) - adapter.submitList(result.data) - } + getMoviesDao() + getAndSaveMovies() + } + + private fun getMoviesDao() { + observeMoviesUseCase.invoke() + .catch { + Timber.i("Test error") + }.onEach { result -> + Timber.i("Test loadMovies result:$result") + val current = moviesMutableFlow.first() as? MoviesState.Loaded + val newState = current?.copy(movies = result) + if (newState == null) { + moviesMutableFlow.emit(MoviesState.Loaded(result)) + } else { + moviesMutableFlow.emit(newState) } - } + + adapter.submitList(result) + } + .flowOn(Dispatchers.IO) + .launchIn(viewModelScope) + } + + private fun getAndSaveMovies() { + getMoviesRestUseCase.invoke() + .catch { + Timber.i("Test error") + }.onEach { + addMoviesToDaoUseCase.invoke(it) + } + .flowOn(Dispatchers.IO) + .launchIn(viewModelScope) } fun likeMovie(movie: Movie) { - GlobalScope.launch { - if (movie.liked) { + viewModelScope.launch { + if (!movie.liked) { likeMovieUseCase(movie.id) } else { dislikeMovieUseCase(movie.id) @@ -49,7 +82,20 @@ class MoviesViewModel @Inject constructor( } } + fun dismissMovieDetails() { + viewModelScope.launch { + val current = moviesMutableFlow.first() as? MoviesState.Loaded + val newState = current?.copy(showInfo = false, movieInfo = null) + newState?.let { moviesMutableFlow.emit(it) } + } + } + fun openMovieDetails(movie: Movie) { - // TODO: todo todo todo todo + Timber.i("Click xxx:$movie") + viewModelScope.launch { + val current = moviesMutableFlow.first() as? MoviesState.Loaded + val newState = current?.copy(showInfo = true, movieInfo = movie) + newState?.let { moviesMutableFlow.emit(it) } + } } } \ No newline at end of file diff --git a/feature-movies/src/main/java/app/bettermetesttask/movies/sections/compose/MovieInfoBottomSheet.kt b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/compose/MovieInfoBottomSheet.kt new file mode 100644 index 0000000..759df63 --- /dev/null +++ b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/compose/MovieInfoBottomSheet.kt @@ -0,0 +1,94 @@ +package app.bettermetesttask.movies.sections.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.bettermetesttask.domainmovies.entries.Movie +import coil3.compose.AsyncImage +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MovieInfoBottomSheet(movie: Movie?, onDismissRequest: () -> Unit = {}) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = { + onDismissRequest.invoke() + }, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 16.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(8.dp) + .width(50.dp) + .height(6.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primary) + ) + } + ) { + BottomSheetContent(movie = movie, dismiss = { + scope.launch { + sheetState.hide() + onDismissRequest.invoke() + } + }) + } +} + +@Composable +fun BottomSheetContent(movie: Movie?, dismiss: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + AsyncImage( + model = movie?.posterPath, + contentDescription = "Movie Poster", + modifier = Modifier + .size(160.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Gray) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text(text = movie?.title ?: "empty", fontSize = 24.sp, color = Color.Black) + Text(text = movie?.description ?: "empty", fontSize = 22.sp, color = Color.Gray) + Spacer(modifier = Modifier.width(16.dp)) + + Button(onClick = dismiss) { + Text("Close") + } + } + } +} diff --git a/feature-movies/src/main/java/app/bettermetesttask/movies/sections/compose/MoviesComposeFragment.kt b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/compose/MoviesComposeFragment.kt index 8d4f484..3416af7 100644 --- a/feature-movies/src/main/java/app/bettermetesttask/movies/sections/compose/MoviesComposeFragment.kt +++ b/feature-movies/src/main/java/app/bettermetesttask/movies/sections/compose/MoviesComposeFragment.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -71,11 +72,22 @@ class MoviesComposeFragment : Fragment(), Injectable { ) setContent { val viewState by viewModel.moviesStateFlow.collectAsState() - MoviesComposeScreen(viewState, likeMovie = { movie -> - viewModel.likeMovie(movie) - }, viewLoaded = { - viewModel.loadMovies() - }) + + MoviesComposeScreen( + viewState, + likeMovie = { movie -> + viewModel.likeMovie(movie) + }, + infoMovie = { movie -> + viewModel.openMovieDetails(movie) + }, + dismissInfo = { + viewModel.dismissMovieDetails() + }, + viewLoaded = { + viewModel.loadMovies() + } + ) } } } @@ -85,6 +97,8 @@ class MoviesComposeFragment : Fragment(), Injectable { private fun MoviesComposeScreen( moviesState: MoviesState, likeMovie: (Movie) -> Unit, + infoMovie: (Movie) -> Unit, + dismissInfo: () -> Unit, viewLoaded: () -> Unit ) { viewLoaded() @@ -94,15 +108,29 @@ private fun MoviesComposeScreen( .background(Color.White) ) { when (moviesState) { - MoviesState.Initial -> {} + MoviesState.Initial -> { + + } + is MoviesState.Loaded -> { LazyColumn { items(moviesState.movies) { item -> MovieItem(item, onLikeClicked = { likeMovie(item) + }, onMoveClicked = { + infoMovie(item) }) } } + + if (moviesState.showInfo) { + MovieInfoBottomSheet( + movie = moviesState.movieInfo, + onDismissRequest = { + dismissInfo.invoke() + } + ) + } } MoviesState.Loading -> { @@ -118,11 +146,16 @@ private fun MoviesComposeScreen( } @Composable -fun MovieItem(movie: Movie, onLikeClicked: (Int) -> Unit) { +fun MovieItem( + movie: Movie, + onLikeClicked: (Int) -> Unit, + onMoveClicked: () -> Unit +) { Card( modifier = Modifier .fillMaxWidth() - .padding(8.dp), + .padding(8.dp) + .clickable { onMoveClicked.invoke() }, shape = RoundedCornerShape(12.dp), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { @@ -161,18 +194,20 @@ fun MovieItem(movie: Movie, onLikeClicked: (Int) -> Unit) { } } + @Composable @Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) private fun PreviewsMoviesComposeScreen() { - MoviesComposeScreen(MoviesState.Loaded( - List(20) { index -> - Movie( - index, - "Title $index", - "Overview $index", - null, - liked = index % 2 == 0, - ) - } - ), likeMovie = {}, viewLoaded = {}) + MoviesComposeScreen( + MoviesState.Loaded( + List(20) { index -> + Movie( + index, + "Title $index", + "Overview $index", + null, + liked = index % 2 == 0, + ) + } + ), likeMovie = {}, infoMovie = {}, dismissInfo = {}, viewLoaded = {}) } \ No newline at end of file