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.