From b3dbaea067758e2b314ca7f5c77eb10b8945a22c Mon Sep 17 00:00:00 2001 From: SIKV Date: Wed, 3 Jul 2024 21:05:34 +0300 Subject: [PATCH] Migrate search screen to Compose --- .../sikv/photos/compose/ui/BackAction.kt | 21 ++ compose-ui/src/main/res/values/strings.xml | 1 + .../sikv/photos/config/ConfigProvider.kt | 4 +- feature/search/build.gradle | 3 + .../github/sikv/photos/search/SearchQuery.kt | 5 - .../sikv/photos/search/SearchUiState.kt | 14 + .../sikv/photos/search/SearchViewModel.kt | 79 +++-- .../sikv/photos/search/ui/SearchFragment.kt | 196 +++--------- .../sikv/photos/search/ui/SearchScreen.kt | 302 ++++++++++++++++++ .../photos/search/ui/SingleSearchFragment.kt | 177 ---------- .../src/main/res/layout/fragment_search.xml | 76 ----- .../res/layout/fragment_single_search.xml | 34 -- .../search/src/main/res/values/strings.xml | 5 +- 13 files changed, 451 insertions(+), 466 deletions(-) create mode 100644 compose-ui/src/main/java/com/github/sikv/photos/compose/ui/BackAction.kt delete mode 100644 feature/search/src/main/java/com/github/sikv/photos/search/SearchQuery.kt create mode 100644 feature/search/src/main/java/com/github/sikv/photos/search/SearchUiState.kt create mode 100644 feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchScreen.kt delete mode 100644 feature/search/src/main/java/com/github/sikv/photos/search/ui/SingleSearchFragment.kt delete mode 100644 feature/search/src/main/res/layout/fragment_search.xml delete mode 100644 feature/search/src/main/res/layout/fragment_single_search.xml diff --git a/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/BackAction.kt b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/BackAction.kt new file mode 100644 index 00000000..f02a6ff6 --- /dev/null +++ b/compose-ui/src/main/java/com/github/sikv/photos/compose/ui/BackAction.kt @@ -0,0 +1,21 @@ +package com.github.sikv.photos.compose.ui + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource + +@Composable +fun BackAction( + onBackClick: () -> Unit +) { + IconButton( + onClick = onBackClick + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back_24dp), + contentDescription = stringResource(id = R.string.navigate_back) + ) + } +} diff --git a/compose-ui/src/main/res/values/strings.xml b/compose-ui/src/main/res/values/strings.xml index 7c240ac7..3abe7909 100644 --- a/compose-ui/src/main/res/values/strings.xml +++ b/compose-ui/src/main/res/values/strings.xml @@ -5,4 +5,5 @@ Download More Switch layout + Navigate back diff --git a/config/src/main/java/com/github/sikv/photos/config/ConfigProvider.kt b/config/src/main/java/com/github/sikv/photos/config/ConfigProvider.kt index 87e5402b..795dd52f 100644 --- a/config/src/main/java/com/github/sikv/photos/config/ConfigProvider.kt +++ b/config/src/main/java/com/github/sikv/photos/config/ConfigProvider.kt @@ -9,8 +9,8 @@ class ConfigProvider @Inject constructor( private val featureFlagProvider: FeatureFlagProvider ) { - fun getSearchSources(): Set { - val sources = mutableSetOf() + fun getSearchSources(): List { + val sources = mutableListOf() if (featureFlagProvider.isFeatureEnabled(FeatureFlag.SEARCH_SOURCE_PEXELS)) { sources.add(PhotoSource.PEXELS) diff --git a/feature/search/build.gradle b/feature/search/build.gradle index d7c5a6ab..5d9f7e71 100644 --- a/feature/search/build.gradle +++ b/feature/search/build.gradle @@ -27,6 +27,7 @@ dependencies { implementation project(':compose-ui') implementation project(':navigation') implementation project(':photo-list-ui') + implementation project(':photo-usecase') implementation project(':feature:recommendations') implementation libs.material @@ -35,6 +36,7 @@ dependencies { implementation libs.androidx.compose.material3 implementation libs.accompanist.themeadapter.material3 implementation libs.androidx.lifecycle.viewmodel.compose + implementation libs.androidx.lifecycle.runtime.compose implementation libs.inject kapt libs.hilt.compiler @@ -42,4 +44,5 @@ dependencies { implementation libs.androidx.hilt.navigation.compose implementation libs.androidx.paging.runtime + implementation libs.androidx.paging.compose } diff --git a/feature/search/src/main/java/com/github/sikv/photos/search/SearchQuery.kt b/feature/search/src/main/java/com/github/sikv/photos/search/SearchQuery.kt deleted file mode 100644 index 170c5a0e..00000000 --- a/feature/search/src/main/java/com/github/sikv/photos/search/SearchQuery.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.github.sikv.photos.search - -internal data class SearchQuery( - val query: String -) diff --git a/feature/search/src/main/java/com/github/sikv/photos/search/SearchUiState.kt b/feature/search/src/main/java/com/github/sikv/photos/search/SearchUiState.kt new file mode 100644 index 00000000..4d4f0aa1 --- /dev/null +++ b/feature/search/src/main/java/com/github/sikv/photos/search/SearchUiState.kt @@ -0,0 +1,14 @@ +package com.github.sikv.photos.search + +import androidx.paging.PagingData +import com.github.sikv.photos.domain.ListLayout +import com.github.sikv.photos.domain.Photo +import com.github.sikv.photos.domain.PhotoSource +import kotlinx.coroutines.flow.Flow + +internal data class SearchUiState( + val query: String? = null, + val photoSources: List = emptyList(), + val photos: Map>?> = emptyMap(), + val listLayout: ListLayout = ListLayout.GRID +) diff --git a/feature/search/src/main/java/com/github/sikv/photos/search/SearchViewModel.kt b/feature/search/src/main/java/com/github/sikv/photos/search/SearchViewModel.kt index aa96ebfd..72cb8f6f 100644 --- a/feature/search/src/main/java/com/github/sikv/photos/search/SearchViewModel.kt +++ b/feature/search/src/main/java/com/github/sikv/photos/search/SearchViewModel.kt @@ -1,65 +1,108 @@ package com.github.sikv.photos.search +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.github.sikv.photos.config.ConfigProvider -import com.github.sikv.photos.data.repository.FavoritesRepository +import com.github.sikv.photos.data.repository.FavoritesRepository2 import com.github.sikv.photos.data.repository.PhotosRepository +import com.github.sikv.photos.domain.ListLayout import com.github.sikv.photos.domain.Photo import com.github.sikv.photos.domain.PhotoSource +import com.github.sikv.photos.navigation.args.SearchFragmentArguments +import com.github.sikv.photos.navigation.args.fragmentArguments import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class SearchViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val photosRepository: PhotosRepository, - private val favoritesRepository: FavoritesRepository, + private val favoritesRepository: FavoritesRepository2, private val configProvider: ConfigProvider ) : ViewModel() { - private val mutableSearchQueryState = MutableStateFlow(null) - val searchQueryState: StateFlow = mutableSearchQueryState + private val mutableUiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = mutableUiState - fun favoriteUpdates(): Flow { - return favoritesRepository.favoriteUpdates() + init { + val args = savedStateHandle.fragmentArguments() + + mutableUiState.update { state -> + state.copy( + query = args.query, + photoSources = configProvider.getSearchSources() + ) + } + } + + fun isFavorite(photo: Photo): Flow { + return favoritesRepository.getFavorites() + .map { photos -> + photos.contains(photo) + } } fun toggleFavorite(photo: Photo) { - favoritesRepository.invertFavorite(photo) + viewModelScope.launch { + favoritesRepository.invertFavorite(photo) + } } - fun requestSearch(text: String) { - mutableSearchQueryState.update { - SearchQuery(query = text) + fun switchListLayout() { + val listLayout = uiState.value.listLayout + + val switchedListLayout = when (listLayout) { + ListLayout.LIST -> ListLayout.GRID + ListLayout.GRID -> ListLayout.LIST + } + + mutableUiState.update { state -> + state.copy(listLayout = switchedListLayout) } } - fun clearSearch() { - mutableSearchQueryState.update { - null + fun onSearchQueryChange(text: String) { + mutableUiState.update { state -> + state.copy(query = text) } } - fun searchPhotos(photoSource: PhotoSource, searchQuery: SearchQuery): Flow>? { - val queryTrimmed = searchQuery.query.trim() + fun performSearch() { + val query = uiState.value.query?.trim().orEmpty() + + if (query.isEmpty()) { + return + } + + val mutableSearchFlows = uiState.value.photos.toMutableMap() - if (queryTrimmed.isEmpty()) { - return null + uiState.value.photoSources.forEach { photoSource -> + mutableSearchFlows[photoSource] = searchPhotos(query, photoSource) } + mutableUiState.update { state -> + state.copy(photos = mutableSearchFlows) + } + } + + private fun searchPhotos(query: String, photoSource: PhotoSource): Flow> { return Pager( config = configProvider.getPagingConfig(), pagingSourceFactory = { SearchPhotosPagingSource( photosRepository, photoSource, - queryTrimmed + query ) } ).flow diff --git a/feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchFragment.kt b/feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchFragment.kt index ea883ccc..f607b844 100644 --- a/feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchFragment.kt +++ b/feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchFragment.kt @@ -1,171 +1,63 @@ package com.github.sikv.photos.search.ui import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import com.github.sikv.photos.common.ui.* -import com.github.sikv.photos.config.ConfigProvider -import com.github.sikv.photos.navigation.args.SearchFragmentArguments -import com.github.sikv.photos.navigation.args.SingleSearchFragmentArguments -import com.github.sikv.photos.navigation.args.fragmentArguments -import com.github.sikv.photos.navigation.args.withArguments -import com.github.sikv.photos.search.SearchViewModel -import com.github.sikv.photos.search.databinding.FragmentSearchBinding -import com.google.android.material.tabs.TabLayoutMediator +import androidx.compose.material3.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.navigation.findNavController +import com.github.sikv.photo.usecase.PhotoActionsUseCase +import com.github.sikv.photos.navigation.args.PhotoDetailsFragmentArguments +import com.github.sikv.photos.navigation.route.PhotoDetailsRoute +import com.google.accompanist.themeadapter.material3.Mdc3Theme import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint -class SearchFragment : BaseFragment() { - - override val overrideBackground: Boolean = true +class SearchFragment : Fragment() { @Inject - lateinit var configProvider: ConfigProvider - - private val viewModel: SearchViewModel by activityViewModels() - private val args by fragmentArguments() - - private lateinit var viewPagerAdapter: ViewPagerAdapter - - private var _binding: FragmentSearchBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentSearchBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + lateinit var photoDetailsRoute: PhotoDetailsRoute - setupToolbarWithBackButton( - title = null, - navigationOnClickListener = { - findNavController().popBackStack() - } + @Inject + lateinit var photoActionsUseCase: PhotoActionsUseCase + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT ) - - setupViewPager { - if (savedInstanceState == null) { - args.query?.let { query -> - binding.searchEdit.append(query) - performSearch(query) + setContent { + Mdc3Theme { + Surface { + SearchScreen( + onBackClick = { + findNavController().popBackStack() + }, + onPhotoClick = { photo -> + photoDetailsRoute.present(findNavController(), PhotoDetailsFragmentArguments(photo)) + }, + onPhotoAttributionClick = { photo -> + photoActionsUseCase.photoAttributionClick(photo) + }, + onPhotoActionsClick = { photo -> + photoActionsUseCase.openMoreActions(requireNotNull(activity), photo) + }, + onSharePhotoClick = { photo -> + photoActionsUseCase.sharePhoto(requireNotNull(activity), photo) + }, + onDownloadPhotoClick = { photo -> + photoActionsUseCase.downloadPhoto(requireNotNull(activity), photo) + } + ) } - - shownKeyboardIfNeeded() } } - - setListeners() - changeClearButtonVisibility(false, withAnimation = false) - - // TODO: Fix this. -// navigation?.setOnBackPressedListener(object : -// com.github.sikv.photos.navigation.OnBackPressedListener { -// override fun onBackPressed() { -// viewModel.clearSearch() -// } -// }) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun performSearch(text: String) { - context?.hideSoftInput(binding.searchEdit) - viewModel.requestSearch(text) - } - - private fun shownKeyboardIfNeeded() { - val showKeyboard = args.query == null - var keyboardShown = false - - if (showKeyboard) { - if (!keyboardShown) { - keyboardShown = requireActivity().showSoftInput(binding.searchEdit) - } - - // TODO Fix crash. -// binding.searchEdit.viewTreeObserver.addOnWindowFocusChangeListener(object : -// ViewTreeObserver.OnWindowFocusChangeListener { -// override fun onWindowFocusChanged(hasFocus: Boolean) { -// if (hasFocus && !keyboardShown) { -// keyboardShown = requireActivity().showSoftInput(binding.searchEdit) -// } -// -// binding.searchEdit.viewTreeObserver?.removeOnWindowFocusChangeListener(this) -// } -// }) - } - } - - private fun changeClearButtonVisibility(visible: Boolean, withAnimation: Boolean = true) { - val newVisibility = if (visible) View.VISIBLE else View.INVISIBLE - - if (binding.searchClearButton.visibility != newVisibility) { - if (withAnimation) { - binding.searchClearButton.changeVisibilityWithAnimation(newVisibility) - } else { - binding.searchClearButton.visibility = newVisibility - } - } - } - - private fun setupViewPager(after: () -> Unit) { - val searchSources = configProvider.getSearchSources().toList() - - if (searchSources.isNotEmpty()) { - viewPagerAdapter = ViewPagerAdapter(this, searchSources.size) { position -> - SingleSearchFragment() - .withArguments(SingleSearchFragmentArguments(searchSources[position])) - } - - binding.viewPager.adapter = viewPagerAdapter - binding.viewPager.offscreenPageLimit = searchSources.size - - TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> - tab.text = searchSources[position].title - }.attach() - } - - binding.viewPager.post { - after() - } - } - - private fun setListeners() { - binding.searchEdit.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_SEARCH) { - performSearch(binding.searchEdit.text.toString()) - return@setOnEditorActionListener true - } - return@setOnEditorActionListener false - } - - binding.searchEdit.addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(editable: Editable?) { - changeClearButtonVisibility(editable?.isNotEmpty() ?: false) - } - - override fun beforeTextChanged(charSequence: CharSequence?, p1: Int, p2: Int, p3: Int) { - } - - override fun onTextChanged(charSequence: CharSequence?, p1: Int, p2: Int, p3: Int) { - } - }) - - binding.searchClearButton.setOnClickListener { - binding.searchEdit.text?.clear() - context?.showSoftInput(binding.searchEdit) - } } } diff --git a/feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchScreen.kt b/feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchScreen.kt new file mode 100644 index 00000000..eb1dc151 --- /dev/null +++ b/feature/search/src/main/java/com/github/sikv/photos/search/ui/SearchScreen.kt @@ -0,0 +1,302 @@ +package com.github.sikv.photos.search.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.github.sikv.photos.compose.ui.BackAction +import com.github.sikv.photos.compose.ui.DynamicPhotoItem +import com.github.sikv.photos.compose.ui.Spacing +import com.github.sikv.photos.compose.ui.SwitchLayoutAction +import com.github.sikv.photos.domain.ListLayout +import com.github.sikv.photos.domain.Photo +import com.github.sikv.photos.search.R +import com.github.sikv.photos.search.SearchUiState +import com.github.sikv.photos.search.SearchViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun SearchScreen( + onBackClick: () -> Unit, + onPhotoClick: (Photo) -> Unit, + onPhotoAttributionClick: (Photo) -> Unit, + onPhotoActionsClick: (Photo) -> Unit, + onSharePhotoClick: (Photo) -> Unit, + onDownloadPhotoClick: (Photo) -> Unit, + viewModel: SearchViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { uiState.photoSources.size }) + val selectedTabIndex by remember { derivedStateOf { pagerState.currentPage } } + + Scaffold( + topBar = { + TopBar( + onBackClick = onBackClick, + uiState = uiState, + viewModel = viewModel + ) + } + ) { padding -> + Column(modifier = Modifier + .fillMaxWidth() + .padding(padding) + ) { + TabRow(selectedTabIndex = selectedTabIndex) { + uiState.photoSources.forEachIndexed { index, photoSource -> + Tab( + text = { Text(photoSource.title) }, + selected = selectedTabIndex == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + } + ) + } + } + SearchContent( + pagerState = pagerState, + uiState = uiState, + onPhotoClick = onPhotoClick, + onPhotoAttributionClick = onPhotoAttributionClick, + onPhotoActionsClick = onPhotoActionsClick, + onSharePhotoClick = onSharePhotoClick, + onDownloadPhotoClick = onDownloadPhotoClick, + viewModel = viewModel + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + onBackClick: () -> Unit, + uiState: SearchUiState, + viewModel: SearchViewModel +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + // TODO: Do not request focus when the screen is resumed. + focusRequester.requestFocus() + } + + TopAppBar( + navigationIcon = { BackAction(onBackClick = onBackClick) }, + title = { + TextField( + value = uiState.query ?: "", + onValueChange = { newQuery -> + viewModel.onSearchQueryChange(newQuery) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + viewModel.performSearch() + focusManager.clearFocus() + } + ), + singleLine = true, + placeholder = { Text(text = stringResource(R.string.search_for_photos)) }, + trailingIcon = { + if (uiState.query.isNullOrBlank().not()) { + IconButton( + onClick = { + viewModel.onSearchQueryChange("") + focusRequester.requestFocus() + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close_22dp), + contentDescription = stringResource(R.string.clear) + ) + } + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + }, + actions = { + SwitchLayoutAction( + listLayout = uiState.listLayout, + onSwitchLayoutClick = viewModel::switchListLayout + ) + } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SearchContent( + pagerState: PagerState, + uiState: SearchUiState, + onPhotoClick: (Photo) -> Unit, + onPhotoAttributionClick: (Photo) -> Unit, + onPhotoActionsClick: (Photo) -> Unit, + onSharePhotoClick: (Photo) -> Unit, + onDownloadPhotoClick: (Photo) -> Unit, + viewModel: SearchViewModel +) { + HorizontalPager(state = pagerState) { index -> + val photoSource = uiState.photoSources[index] + val photos = uiState.photos[photoSource]?.collectAsLazyPagingItems() + + if (photos == null) { + // TODO: Display 'Start search' text. + } else { + when (photos.loadState.refresh) { + is LoadState.Error -> Error() + LoadState.Loading -> Loading() + is LoadState.NotLoading -> Photos( + listLayout = uiState.listLayout, + photos = photos, + onPhotoClick = onPhotoClick, + onPhotoAttributionClick = onPhotoAttributionClick, + onPhotoActionsClick = onPhotoActionsClick, + onSharePhotoClick = onSharePhotoClick, + onDownloadPhotoClick = onDownloadPhotoClick, + viewModel = viewModel, + ) + } + } + } +} + +@Composable +private fun Photos( + listLayout: ListLayout, + photos: LazyPagingItems, + onPhotoClick: (Photo) -> Unit, + onPhotoAttributionClick: (Photo) -> Unit, + onPhotoActionsClick: (Photo) -> Unit, + onSharePhotoClick: (Photo) -> Unit, + onDownloadPhotoClick: (Photo) -> Unit, + viewModel: SearchViewModel +) { + if (photos.itemCount == 0) { + NoResults() + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(listLayout.spanCount) + ) { + items(photos.itemCount) { index -> + photos[index]?.let { photo -> + val isFavorite by viewModel.isFavorite(photo) + .collectAsStateWithLifecycle(initialValue = false) + + DynamicPhotoItem( + photo = photo, + isFavorite = isFavorite, + listLayout = listLayout, + onPhotoClick = onPhotoClick, + onPhotoAttributionClick = onPhotoAttributionClick, + onPhotoActionsClick = onPhotoActionsClick, + onToggleFavoriteClick = viewModel::toggleFavorite, + onSharePhotoClick = onSharePhotoClick, + onDownloadPhotoClick = onDownloadPhotoClick, + ) + } + } + } + } +} + +@Composable +private fun Loading() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun NoResults() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .padding(Spacing.Two) + ) { + Text( + text = stringResource(R.string.no_search_results), + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun Error() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .padding(Spacing.Two) + ) { + Text( + text = stringResource(R.string.error), + textAlign = TextAlign.Center + ) + } +} diff --git a/feature/search/src/main/java/com/github/sikv/photos/search/ui/SingleSearchFragment.kt b/feature/search/src/main/java/com/github/sikv/photos/search/ui/SingleSearchFragment.kt deleted file mode 100644 index 6ec0e738..00000000 --- a/feature/search/src/main/java/com/github/sikv/photos/search/ui/SingleSearchFragment.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.github.sikv.photos.search.ui - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.paging.LoadState -import com.github.sikv.photo.list.ui.PhotoActionDispatcher -import com.github.sikv.photo.list.ui.adapter.PhotoPagingAdapter -import com.github.sikv.photo.list.ui.updateLoadState -import com.github.sikv.photos.common.DownloadService -import com.github.sikv.photos.common.PhotoLoader -import com.github.sikv.photos.common.ui.BaseFragment -import com.github.sikv.photos.common.ui.disableChangeAnimations -import com.github.sikv.photos.common.ui.setVisibilityAnimated -import com.github.sikv.photos.data.repository.FavoritesRepository -import com.github.sikv.photos.domain.Photo -import com.github.sikv.photos.navigation.args.SingleSearchFragmentArguments -import com.github.sikv.photos.navigation.args.fragmentArguments -import com.github.sikv.photos.navigation.route.PhotoDetailsRoute -import com.github.sikv.photos.navigation.route.SetWallpaperRoute -import com.github.sikv.photos.search.SearchQuery -import com.github.sikv.photos.search.SearchViewModel -import com.github.sikv.photos.search.databinding.FragmentSingleSearchBinding -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch -import javax.inject.Inject - -@AndroidEntryPoint -internal class SingleSearchFragment : BaseFragment() { - - @Inject - lateinit var favoritesRepository: FavoritesRepository - - @Inject - lateinit var downloadService: DownloadService - - @Inject - lateinit var photoLoader: PhotoLoader - - @Inject - lateinit var photoDetailsRoute: PhotoDetailsRoute - - @Inject - lateinit var setWallpaperRoute: SetWallpaperRoute - - private val viewModel: SearchViewModel by activityViewModels() - private val args by fragmentArguments() - - private val photoActionDispatcher by lazy { - PhotoActionDispatcher( - fragment = this, - downloadService = downloadService, - photoLoader = photoLoader, - photoDetailsRoute = photoDetailsRoute, - setWallpaperRoute = setWallpaperRoute, - onToggleFavorite = viewModel::toggleFavorite, - onShowMessage = ::showMessage - ) - } - - private lateinit var photoAdapter: PhotoPagingAdapter - - private var _binding: FragmentSingleSearchBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - photoAdapter = PhotoPagingAdapter( - photoLoader = photoLoader, - favoritesRepository = favoritesRepository, - lifecycleScope = lifecycleScope, - listener = photoActionDispatcher - ) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentSingleSearchBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.photosRecycler.adapter = photoAdapter - binding.photosRecycler.disableChangeAnimations() - - binding.loadingView.isVisible = false - binding.noResultsView.isVisible = false - binding.loadingErrorView.isVisible = false - - binding.loadingErrorView.setTryAgainClickListener { - photoAdapter.retry() - } - - addLoadStateListener() - - collectSearchQuery() - collectFavoriteUpdates() - } - - override fun onDestroyView() { - super.onDestroyView() - - _binding = null - } - - private fun collectSearchQuery() { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.searchQueryState - .filterNotNull() - .collect { searchQuery -> - searchPhotos(searchQuery) - } - } - } - } - - @SuppressLint("NotifyDataSetChanged") - private fun collectFavoriteUpdates() { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.favoriteUpdates().collect { update -> - when (update) { - is FavoritesRepository.UpdatePhoto -> { - photoAdapter.notifyPhotoChanged(update.photo) - } - is FavoritesRepository.UpdateAll -> { - photoAdapter.notifyDataSetChanged() - } - } - } - } - } - } - - private fun searchPhotos(searchQuery: SearchQuery) { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.searchPhotos(args.photoSource, searchQuery)?.collect { data -> - photoAdapter.submitData(lifecycle, data) - } - } - } - } - - private fun addLoadStateListener() { - photoAdapter.addLoadStateListener { loadState -> - when (loadState.source.refresh) { - is LoadState.NotLoading -> { - binding.photosRecycler.setVisibilityAnimated(View.VISIBLE) - binding.noResultsView.isVisible = photoAdapter.itemCount == 0 - } - is LoadState.Loading -> { - binding.noResultsView.isVisible = false - binding.photosRecycler.setVisibilityAnimated(View.GONE) - } - is LoadState.Error -> { - binding.noResultsView.isVisible = false - binding.photosRecycler.setVisibilityAnimated(View.GONE) - } - } - - binding.loadingView.updateLoadState(loadState) - binding.loadingErrorView.updateLoadState(loadState) - } - } -} diff --git a/feature/search/src/main/res/layout/fragment_search.xml b/feature/search/src/main/res/layout/fragment_search.xml deleted file mode 100644 index 90c438ad..00000000 --- a/feature/search/src/main/res/layout/fragment_search.xml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/search/src/main/res/layout/fragment_single_search.xml b/feature/search/src/main/res/layout/fragment_single_search.xml deleted file mode 100644 index b6390819..00000000 --- a/feature/search/src/main/res/layout/fragment_single_search.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index 2e1f2c82..54c792a3 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -1,8 +1,9 @@ - Clear + Clear Search for photos - No Results Found. Try something similar or more general + No Results Found. Try something similar or more general. Search Voice search + Something went wrong. Please try again.