diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt index 93184fb..ae5a512 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.mohitb117.demo_omdb_api.activities import android.os.Bundle @@ -7,18 +9,22 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -26,18 +32,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.mohitb117.demo_omdb_api.activities.ui.theme.DEMO_OMDB_APITheme +import com.mohitb117.demo_omdb_api.activities.ui.theme.Purple80 import com.mohitb117.demo_omdb_api.datamodels.SearchResult import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody import com.mohitb117.demo_omdb_api.ui.favourites.FavouritesViewModel -import com.mohitb117.demo_omdb_api.ui.search.FavouritesComposableContent +import com.mohitb117.demo_omdb_api.ui.favourites.FavouritesComposableContent import com.mohitb117.demo_omdb_api.ui.search.Result import com.mohitb117.demo_omdb_api.ui.search.SearchComposableContent import com.mohitb117.demo_omdb_api.ui.search.SearchViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.delay @AndroidEntryPoint class LaunchingActivity : ComponentActivity() { @@ -62,13 +69,18 @@ class LaunchingActivity : ComponentActivity() { ) { val searchUiState by searchViewModel.searchResultsViewState.collectAsStateWithLifecycle() val favouritesUiState by favouritesViewModel.favouritedIdsFlow.collectAsStateWithLifecycle() + val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() + val isRefreshing by searchViewModel.isRefreshing.collectAsStateWithLifecycle() LaunchComposableContent( modifier = modifier, searchUiState = searchUiState, favouritesUiState = favouritesUiState, - onSearch = { searchViewModel.loadSearchResult(it) }, - isMovieFavourited = { searchViewModel.favouritedIds.value.contains(it) }, + searchQuery = searchQuery, + onRefresh = { searchViewModel.refresh() }, + isRefreshing = isRefreshing, + onSearchQueryChanged = { searchViewModel.onSearchQueryChanged(it) }, + isMovieFavourited = { searchViewModel.favouritedIds.value.any { fav -> fav.imdbID == it.imdbID } }, onToggleMovieFavorited = { searchViewModel.toggleFavourite(it) }, ) } @@ -78,7 +90,10 @@ class LaunchingActivity : ComponentActivity() { modifier: Modifier = Modifier, searchUiState: Result, favouritesUiState: Set, - onSearch: (String) -> Unit, + searchQuery: String, + isRefreshing: Boolean = false, + onRefresh: () -> Unit = {}, + onSearchQueryChanged: (String) -> Unit, isMovieFavourited: (SearchResult) -> Boolean = { false }, onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, ) { @@ -97,33 +112,41 @@ class LaunchingActivity : ComponentActivity() { onClick = { currentDestination = it }, ) } - } - ) { - var searchQuery by rememberSaveable { mutableStateOf("") } + }) { + val searchResultError = (searchUiState as? Result.Failure)?.error Scaffold( modifier = Modifier.fillMaxSize(), + topBar = { + Text( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(horizontal = 16.dp, vertical = 8.dp), + text = searchResultError?.localizedMessage ?: stringResource(currentDestination.label), + color = if (searchResultError != null) Color.Red else Color.Unspecified, + ) + }, bottomBar = { if (currentDestination == AppDestinations.HOME) { OutlinedTextField( modifier = Modifier .fillMaxWidth() - .background(color = Color.Gray), + .background(color = Purple80), value = searchQuery, - onValueChange = { searchQuery = it }, - label = { Text(text = "Search Movie!") } - ) + onValueChange = { onSearchQueryChanged(it) }, + label = { Text(text = "Search Movie!") }) } } ) { innerPadding -> when (currentDestination) { AppDestinations.HOME -> { SearchMovieInfoLayout( - name = stringResource(currentDestination.label), modifier = Modifier.padding(innerPadding), searchQuery = searchQuery, searchUiState = searchUiState, - onSearch = onSearch, + isRefreshing = isRefreshing, + onRefresh = onRefresh, isMovieFavourited = isMovieFavourited, onToggleMovieFavorited = onToggleMovieFavorited, ) @@ -131,7 +154,6 @@ class LaunchingActivity : ComponentActivity() { else -> { FavouritesComposableLayout( - name = stringResource(currentDestination.label), modifier = Modifier.padding(innerPadding), uiState = favouritesUiState, isMovieFavourited = isMovieFavourited, @@ -145,11 +167,11 @@ class LaunchingActivity : ComponentActivity() { @Composable fun SearchMovieInfoLayout( - name: String, modifier: Modifier = Modifier, searchQuery: String = "", searchUiState: Result, - onSearch: (String) -> Unit, + isRefreshing: Boolean = false, + onRefresh: () -> Unit = {}, isMovieFavourited: (SearchResult) -> Boolean = { false }, onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, ) { @@ -160,17 +182,23 @@ class LaunchingActivity : ComponentActivity() { ) { if (searchQuery.isNotEmpty()) { - LaunchedEffect(searchQuery) { - delay(500) // Debounce for 500ms - onSearch(searchQuery) + val effectiveUiState = when { + searchUiState is Result.Failure && searchUiState.lastCachedData != null -> { + Result.Success(searchUiState.lastCachedData, searchQuery) + } + + else -> searchUiState } SearchComposableContent( modifier = Modifier, - searchUiState = searchUiState, + searchUiState = effectiveUiState, + onRefresh = onRefresh, + isRefreshing = isRefreshing, onToggleMovieFavorited = onToggleMovieFavorited, isMovieFavourited = isMovieFavourited, ) + } else { Text("Nothing to search right now!") } @@ -179,7 +207,6 @@ class LaunchingActivity : ComponentActivity() { @Composable fun FavouritesComposableLayout( - name: String, uiState: Set, modifier: Modifier = Modifier, isMovieFavourited: (SearchResult) -> Boolean = { false }, @@ -205,9 +232,7 @@ class LaunchingActivity : ComponentActivity() { fun SearchMovieInfoLayoutPreview() { DEMO_OMDB_APITheme { SearchMovieInfoLayout( - name = "Android", - searchUiState = Result.Loading(""), - onSearch = {} + searchUiState = Result.Loading("") ) } } @@ -219,7 +244,8 @@ class LaunchingActivity : ComponentActivity() { LaunchComposableContent( searchUiState = Result.Loading(""), favouritesUiState = emptySet(), - onSearch = {} + searchQuery = "", + onSearchQueryChanged = {} ) } } diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/common/CommonUi.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/common/CommonUi.kt index 12bc805..ea79969 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/common/CommonUi.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/common/CommonUi.kt @@ -1,17 +1,22 @@ package com.mohitb117.demo_omdb_api.ui.common import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -31,12 +36,13 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.mohitb117.demo_omdb_api.R import com.mohitb117.demo_omdb_api.activities.ui.theme.DEMO_OMDB_APITheme +import com.mohitb117.demo_omdb_api.activities.ui.theme.Purple40 import com.mohitb117.demo_omdb_api.datamodels.SearchResult import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody -import com.mohitb117.demo_omdb_api.ui.search.FavouritesComposableContent +import com.mohitb117.demo_omdb_api.ui.favourites.FavouritesComposableContent import kotlinx.coroutines.launch -import kotlin.collections.orEmpty +@ExperimentalMaterial3Api @Composable fun MovieItemListComposable( modifier: Modifier, @@ -45,71 +51,103 @@ fun MovieItemListComposable( onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, ) { val searchItems = searchResults.Search.orEmpty() - val coroutineScope = rememberCoroutineScope() if (searchItems.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(modifier = Modifier.fillMaxWidth(), text = "No results found") - } + Text(modifier = Modifier.fillMaxWidth(), text = "No results found") } else { LazyColumn( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxSize(), ) { items( items = searchItems, - key = { item -> item.imdbID } + key = { it.imdbID } ) { item -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Absolute.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - val borderWidth = 4.dp - val roundedCornerShape = RoundedCornerShape(borderWidth) - var isFavourited by rememberSaveable { mutableStateOf(false) } + ListItem( + item = item, + isMovieFavourited = isMovieFavourited, + onToggleMovieFavorited = onToggleMovieFavorited, + ) + } + } + } +} + +@Composable +fun ListItem( + modifier: Modifier = Modifier, + item: SearchResult, + isMovieFavourited: (SearchResult) -> Boolean = { false }, + onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, +) { + val coroutineScope = rememberCoroutineScope() - isFavourited = isMovieFavourited(item) + val borderWidth = 20.dp + val roundedCornerShape = RoundedCornerShape(borderWidth) - AsyncImage( - model = item.poster, - placeholder = painterResource(id = R.mipmap.img), - modifier = Modifier - .size(100.dp) - .padding(5.dp) - .border( - BorderStroke(borderWidth, Color.Gray), - roundedCornerShape - ) - .padding(borderWidth / 2) - .clip(roundedCornerShape), - contentDescription = "Poster", - contentScale = ContentScale.Crop, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .background( + color = Purple40, + shape = roundedCornerShape + ), + verticalAlignment = Alignment.CenterVertically, + ) { + var isFavourited by rememberSaveable { mutableStateOf(false) } - Text( - modifier = Modifier.fillMaxWidth(fraction = .3f), - text = item.title, - ) + isFavourited = isMovieFavourited(item) - Switch( - modifier = Modifier.fillMaxWidth(fraction = .1f), - checked = isFavourited, - onCheckedChange = { - coroutineScope.launch { - val result = onToggleMovieFavorited(item) - isFavourited = result - } - } + Column( + modifier = Modifier.weight(2f), + horizontalAlignment = Alignment.Start, + ) { + AsyncImage( + model = item.poster, + placeholder = painterResource(id = R.mipmap.img), + modifier = Modifier + .size(150.dp) + .padding(5.dp) + .border( + border = BorderStroke(borderWidth/8, Color.Gray), + shape = RoundedCornerShape(borderWidth) ) + .padding(borderWidth/8) + .clip(roundedCornerShape), + contentDescription = "Poster", + contentScale = ContentScale.Crop, + ) + } + + Column( + modifier = Modifier.weight(3f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Text( + text = item.title, + ) + } + + Column( + modifier = Modifier, + horizontalAlignment = Alignment.End, + ) { + Switch( + modifier = Modifier.wrapContentSize().padding(end = 5.dp), + checked = isFavourited, + onCheckedChange = { + coroutineScope.launch { + val result = onToggleMovieFavorited(item) + isFavourited = result + } } - } + ) } } } +@ExperimentalMaterial3Api @Preview(showBackground = true) @Composable fun MovieItemComposablePreview() { @@ -118,13 +156,13 @@ fun MovieItemComposablePreview() { uiState = setOf( SearchResult( imdbID = "tt0076759", - title = "Star Wars: Episode IV - A New Hope", + title = "Star Wars: Episode IV - A New Hope Blah BlahBlahnBlahn", type = "movie", poster = "https://m.media-amazon.com/images/M/MV5BOTA5NjhiOTAtZWM0ZC00MWNhLWE1OTktZDA4MjkwYmY0OTU3XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg" ), SearchResult( imdbID = "tt0080684", - title = "Star Wars: Episode V - The Empire Strikes Back", + title = "Star Wars: Episode V - The Empire Strikes BackBlah Blahn BlahnBlahnBlahnBlahnBlahn", type = "movie", poster = "https://m.media-amazon.com/images/M/MV5BYmU1ZTMwMWUtMWVlUC00MGRiLTgwOTYtOGVjOWFmZGUxMGRmXkEyXkFqcGdeQXVyNjU0OTQ0OTY@._V1_SX300.jpg" ) @@ -133,6 +171,7 @@ fun MovieItemComposablePreview() { } } +@ExperimentalMaterial3Api @Preview(showBackground = true) @Composable fun MovieItemListComposablePreview() { diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/details/BottomSheetDetailsViewModel.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/details/BottomSheetDetailsViewModel.kt index 4b489f0..094cf3f 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/details/BottomSheetDetailsViewModel.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/details/BottomSheetDetailsViewModel.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope import com.mohitb117.demo_omdb_api.datamodels.DetailsResultsBody import com.mohitb117.demo_omdb_api.datamodels.SearchResult import com.mohitb117.demo_omdb_api.repositories.MovieRepository -import com.mohitb117.demo_omdb_api.ui.search.SearchViewState import com.slack.eithernet.ApiResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineExceptionHandler diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesComposable.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesComposable.kt index 47dbd91..6adaec2 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesComposable.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesComposable.kt @@ -1,10 +1,12 @@ -package com.mohitb117.demo_omdb_api.ui.search +package com.mohitb117.demo_omdb_api.ui.favourites +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.mohitb117.demo_omdb_api.datamodels.SearchResult import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody import com.mohitb117.demo_omdb_api.ui.common.MovieItemListComposable +@ExperimentalMaterial3Api @Composable fun FavouritesComposableContent( diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesFragment.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesFragment.kt deleted file mode 100644 index e1e8934..0000000 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesFragment.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.mohitb117.demo_omdb_api.ui.favourites - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.GridLayoutManager -import com.mohitb117.demo_omdb_api.R -import com.mohitb117.demo_omdb_api.activities.LaunchingActivity -import com.mohitb117.demo_omdb_api.adapters.Callbacks -import com.mohitb117.demo_omdb_api.adapters.SearchListAdapter -import com.mohitb117.demo_omdb_api.databinding.FragmentFavouritesBinding -import com.mohitb117.demo_omdb_api.datamodels.SearchResult -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -/** - * UI to preview the persisted data for a user. - */ -@AndroidEntryPoint -class FavouritesFragment : Fragment() { - - @Inject - internal lateinit var adapter: SearchListAdapter - - private val favouritesViewModel: FavouritesViewModel by viewModels() - - private lateinit var binding: FragmentFavouritesBinding - - /** - * HACK: This should ideally be propagated as separate events / Kotlin Coroutine Flow + DB. - */ - private var lastSelectedPosition: Int? = null - - private val searchCallbacks = object : Callbacks { - override fun gotoDetails(imdbId: String, position: Int) { -// TODO: -// (activity as LaunchingActivity).gotoDetails(imdbId) -// lastSelectedPosition = position - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentFavouritesBinding.inflate(inflater, container, false) - - binding.resultsRecyclerView.layoutManager = GridLayoutManager( - binding.root.context, - resources.getInteger(R.integer.column_count) - ) - - binding.resultsRecyclerView.adapter = adapter - adapter.callbacks = searchCallbacks - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Listen for State Update for items being marked as "fav" and selectively update the item. - favouritesViewModel.getFavItems() - .observe(viewLifecycleOwner) { favItems: List? -> handleFavouriteResults(favItems) } - } - - @SuppressLint("SetTextI18n") - private fun handleFavouriteResults(favItems: List?) { - val hasResults = favItems?.isNotEmpty() ?: false - - binding.apply { - emptyView.isInvisible = hasResults - resultsRecyclerView.isVisible = hasResults - resultsSummary.text = "Fav Summary returned: ${favItems?.size}" - } - - if (hasResults) { - adapter.submitList(favItems) - } - adapter.submitList(favItems) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchComposable.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchComposable.kt index 18df447..da97fbd 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchComposable.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchComposable.kt @@ -1,41 +1,63 @@ package com.mohitb117.demo_omdb_api.ui.search +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.mohitb117.demo_omdb_api.datamodels.SearchResult import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody import com.mohitb117.demo_omdb_api.ui.common.MovieItemListComposable +@ExperimentalMaterial3Api @Composable fun SearchComposableContent( modifier: Modifier = Modifier, searchUiState: Result, + isRefreshing: Boolean = false, + onRefresh: () -> Unit = {}, isMovieFavourited: (SearchResult) -> Boolean = { false }, onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, ) { - when (searchUiState) { - is Result.Success -> { - MovieItemListComposable( - modifier = modifier, - searchResults = searchUiState.value, - isMovieFavourited = isMovieFavourited, - onToggleMovieFavorited = onToggleMovieFavorited, - ) - } + val scrollState = rememberScrollState() - is Result.Failure -> { - Text(text = "Error seen = ${searchUiState.error.localizedMessage.orEmpty()}") - } + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (searchUiState) { + is Result.Success -> { + MovieItemListComposable( + modifier = modifier, + searchResults = searchUiState.value, + isMovieFavourited = isMovieFavourited, + onToggleMovieFavorited = onToggleMovieFavorited, + ) + } - is Result.Loading -> { - CircularProgressIndicator( - modifier = Modifier.wrapContentSize(), - ) - } + is Result.Loading -> { + CircularProgressIndicator( + modifier = Modifier.wrapContentSize(), + ) + } - else -> Text("Something went wrong! $searchUiState") + else -> Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + contentAlignment = Alignment.Center + ) { + Text("😭No data for you as Something went wrong!😭") + } + } } } diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchFragment.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchFragment.kt deleted file mode 100644 index b53ea89..0000000 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchFragment.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.mohitb117.demo_omdb_api.ui.search - -import android.annotation.SuppressLint -import android.os.Bundle -import android.util.Log -import android.view.* -import android.view.inputmethod.EditorInfo -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.GridLayoutManager -import com.mohitb117.demo_omdb_api.R -import com.mohitb117.demo_omdb_api.adapters.SearchListAdapter -import com.mohitb117.demo_omdb_api.databinding.FragmentSearchBinding -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -import android.widget.TextView -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import com.mohitb117.demo_omdb_api.activities.LaunchingActivity -import com.mohitb117.demo_omdb_api.adapters.Callbacks -import com.mohitb117.demo_omdb_api.datamodels.SearchResult - -/** - * UI to search the OMDB API. - */ -@AndroidEntryPoint -class SearchFragment : Fragment() { - - private lateinit var viewBinding: FragmentSearchBinding - - @Inject - internal lateinit var adapter: SearchListAdapter - - private val searchViewModel: SearchViewModel by viewModels() - - /** - * HACK: This should ideally be propagated as separate events / Kotlin Coroutine Flow + DB. - */ - private var lastSelectedPosition: Int? = null - - private val searchCallbacks = object : Callbacks { - override fun gotoDetails(imdbId: String, position: Int) { -// TODO: -// (activity as LaunchingActivity).gotoDetails(imdbId) -// lastSelectedPosition = position - } - } - - private val nextListener = object : TextView.OnEditorActionListener { - override fun onEditorAction(textView: TextView?, actionId: Int, event: KeyEvent?): Boolean { - if (actionId == EditorInfo.IME_ACTION_NEXT) { - processSearch() - return true - } - return false - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - viewBinding = FragmentSearchBinding.inflate(inflater, container, false) - - viewBinding.resultsRecyclerView.layoutManager = GridLayoutManager( - viewBinding.root.context, - resources.getInteger(R.integer.column_count) - ) - viewBinding.resultsRecyclerView.adapter = adapter - adapter.callbacks = searchCallbacks - - return viewBinding.root - } - - private fun processSearch() { - val searchKey = viewBinding.searchTermEdittext.text.toString() - searchViewModel.loadSearchResult(searchKey) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewBinding.apply { - submitButton.setOnClickListener { processSearch() } - searchTermEdittext.setOnEditorActionListener(nextListener) - } - -// // Listen for Search Result Update. -// searchViewModel.viewState -// .observe(viewLifecycleOwner) { viewState -> handleSearchViewState(viewState) } - - // Listen for State Update for items being marked as "fav" and selectively update the item. - searchViewModel.getFavItems() - .observe(viewLifecycleOwner) { favItems: List? -> updateAdapter(favItems) } - } - - private fun updateAdapter(favItems: List?) { - if (favItems?.isNotEmpty() == true) { - - val position = lastSelectedPosition - if (position != null) { - adapter.notifyItemChanged(position) - } - } - } - - @SuppressLint("SetTextI18n") - private fun handleSearchViewState(viewState: SearchViewState?) { - viewState?.errorReceived?.let { - viewBinding.resultsSummary.apply { - text = " Error Received: $it" - setTextColor(resources.getColor(android.R.color.holo_red_dark, context.theme)) - } - Log.e(TAG, "Error Received $it") - } - - viewState?.searchResult?.let { - val hasResults = it.Search?.isNotEmpty() ?: false - - viewBinding.apply { - emptyView.isInvisible = hasResults - resultsRecyclerView.isVisible = hasResults - - resultsSummary.apply { - text = """ - Result summary for ${viewState.searchTerm} returned: ${it.totalResults} - but page #1 loaded ${it.Search?.size} results - """.trimIndent() - - setTextColor(resources.getColor(android.R.color.holo_green_light, context.theme)) - } - } - - if (hasResults) { - adapter.submitList(it.Search) - } - } - } - - private companion object { - private val TAG = SearchFragment::class.java.simpleName - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt index 789273d..67d7b20 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt @@ -11,11 +11,14 @@ import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody import com.mohitb117.demo_omdb_api.repositories.MovieRepository import com.slack.eithernet.ApiResult import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,79 +26,106 @@ import javax.inject.Inject sealed class Result(open val searchTerm: String) { data class Success(val value: T, override val searchTerm: String) : Result(searchTerm) - data class Failure(val error: Throwable, override val searchTerm: String) : - Result(searchTerm) + data class Failure( + val error: Throwable, + override val searchTerm: String, + val lastCachedData: T? = null + ) : Result(searchTerm) data class Loading(override val searchTerm: String) : Result(searchTerm) } -data class SearchViewState( - val searchTerm: String = "dogs", - val searchResult: SearchResultsBody? = null, - val errorReceived: String? = null -) - /** * ViewModel responsible for fetching / loading results from OMDB. */ @HiltViewModel -class SearchViewModel -@Inject constructor( +class SearchViewModel @Inject constructor( private val repository: MovieRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { + companion object { + private const val KEY_SEARCH_QUERY = "search_query" + private const val KEY_LAST_RESULTS = "last_results" + } + + private var lastCachedData: SearchResultsBody? + get() = savedStateHandle.get(KEY_LAST_RESULTS) + set(value) { + savedStateHandle[KEY_LAST_RESULTS] = value + } + + val searchQuery = savedStateHandle.getStateFlow(KEY_SEARCH_QUERY, "") + private val _viewStateLiveData: MutableLiveData> = - MutableLiveData(Result.Loading("")) + MutableLiveData( + lastCachedData?.let { Result.Success(it, searchQuery.value) } ?: Result.Loading("") + ) val searchResultsViewState = _viewStateLiveData.asFlow().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(1000), - initialValue = Result.Loading("") + initialValue = _viewStateLiveData.value ?: Result.Loading("") ) - // Efficiently hold the IDs of favorited movies - - fun getFavItems() = repository.getFavItems() - private val _favouritedIds = MutableStateFlow>(emptySet()) val favouritedIds = _favouritedIds.asStateFlow() + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing = _isRefreshing.asStateFlow() + init { + observeSearchQuery() + observeFavorites() + } + + @OptIn(FlowPreview::class) + private fun observeSearchQuery() { + searchQuery + .debounce(500) + .onEach { loadSearchResult(it) } + .launchIn(viewModelScope) + } + + private fun observeFavorites() { viewModelScope.launch { repository.getFavItems() .asFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(1000), - initialValue = emptyList() - ) .collect { _favouritedIds.value = it.toSet() } } } - fun loadSearchResult(searchTerm: String) { - _viewStateLiveData.value = Result.Loading(searchTerm) + fun onSearchQueryChanged(query: String) { + savedStateHandle[KEY_SEARCH_QUERY] = query + } - val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.e( - SearchViewModel::class.java.simpleName, - "Error Encountered: ${throwable.localizedMessage}" - ) - val error = Throwable(throwable.localizedMessage) - _viewStateLiveData.value = Result.Failure(error = error, searchTerm = searchTerm) + fun loadSearchResult( + searchTerm: String, + isForceRefresh: Boolean = false, + ) { + if (!isForceRefresh && (_viewStateLiveData.value is Result.Loading && (_viewStateLiveData.value as Result.Loading).searchTerm == searchTerm)) { + return } - viewModelScope.launch(exceptionHandler) { - val result = when ( - val result = withContext(Dispatchers.IO) { repository.loadResults(searchTerm) } - ) { - is ApiResult.Success -> Result.Success( - searchTerm = searchTerm, - value = result.value - ) + if (!isForceRefresh) { + _viewStateLiveData.value = Result.Loading(searchTerm) + } + + viewModelScope.launch { + performSearchInternal(searchTerm) + } + } + + private suspend fun performSearchInternal(searchTerm: String) { + val data = try { + val result = withContext(Dispatchers.IO) { repository.loadResults(searchTerm) } + when (result) { + is ApiResult.Success -> { + lastCachedData = result.value + Result.Success(result.value, searchTerm) + } is ApiResult.Failure -> { val error = when (result) { @@ -105,16 +135,32 @@ class SearchViewModel is ApiResult.Failure.UnknownFailure -> result.error.toString() else -> "Not sure what is going on!!! :sob: " } - - Result.Failure(error = Throwable(error), searchTerm = searchTerm) + Result.Failure(error = Throwable(error), searchTerm = searchTerm, lastCachedData = lastCachedData) } } - - _viewStateLiveData.value = result + } catch (exception: Exception) { + Log.e(SearchViewModel::class.java.simpleName, "Error: ${exception.localizedMessage}") + Result.Failure(exception, searchTerm, lastCachedData) } + + _viewStateLiveData.value = data } suspend fun toggleFavourite(result: SearchResult): Boolean { return repository.toggleFavourite(result) } + + /** + * Refreshes the search results. + */ + fun refresh() { + viewModelScope.launch { + _isRefreshing.value = true + val term = searchQuery.value + if (term.isNotBlank()) { + performSearchInternal(term) + } + _isRefreshing.value = false + } + } } diff --git a/app/src/main/res/layout/activity_launching.xml b/app/src/main/res/layout/activity_launching.xml deleted file mode 100644 index 841fedd..0000000 --- a/app/src/main/res/layout/activity_launching.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation_graph.xml b/app/src/main/res/navigation/mobile_navigation_graph.xml deleted file mode 100644 index e858179..0000000 --- a/app/src/main/res/navigation/mobile_navigation_graph.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - \ No newline at end of file