diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c02d3f5..5528ca1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,6 +30,10 @@ android { } } + buildFeatures { + dataBinding = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -56,9 +60,12 @@ dependencies { implementation(Dependencies.AndroidX.material) implementation(Dependencies.AndroidX.constraintLayout) implementation(Dependencies.AndroidX.viewModelKtx) + implementation(Dependencies.AndroidX.lifeCycleCommon) /*Misc*/ implementation(Dependencies.Misc.timber) + implementation(Dependencies.Misc.viewBindingDelegate) + implementation(Dependencies.Misc.coil) /*Dagger*/ implementation(Dependencies.Dagger.Hilt.android) diff --git a/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/BindingAdapters.kt b/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/BindingAdapters.kt new file mode 100644 index 0000000..cbdec20 --- /dev/null +++ b/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/BindingAdapters.kt @@ -0,0 +1,14 @@ +package ir.zinutech.android.flickrsearch.core.extensions + +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import coil.load +import coil.size.Scale + +@BindingAdapter("imageUrl") +fun ImageView.imageUrl(url: String?) { + load(url){ + crossfade(true) + scale(Scale.FILL) + } +} \ No newline at end of file diff --git a/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/CloseKeyboardScrollListener.kt b/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/CloseKeyboardScrollListener.kt new file mode 100644 index 0000000..d2d9f0c --- /dev/null +++ b/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/CloseKeyboardScrollListener.kt @@ -0,0 +1,12 @@ +package ir.zinutech.android.flickrsearch.core.extensions + +import androidx.recyclerview.widget.RecyclerView + +class CloseKeyboardScrollListener : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + recyclerView.hideKeyboard() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/ViewExtensions.kt b/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/ViewExtensions.kt new file mode 100644 index 0000000..160f601 --- /dev/null +++ b/app/src/main/java/ir/zinutech/android/flickrsearch/core/extensions/ViewExtensions.kt @@ -0,0 +1,33 @@ +package ir.zinutech.android.flickrsearch.core.extensions + +import android.view.LayoutInflater +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.core.content.ContextCompat.getSystemService +import androidx.recyclerview.widget.ListAdapter + + +fun View.inflater(): LayoutInflater { + return LayoutInflater.from(context) +} + +fun ListAdapter<*, *>.clear() { + submitList(null) +} + +fun View.toGone() { + this.visibility = View.GONE +} + +fun View.toInvisible() { + this.visibility = View.INVISIBLE +} + +fun View.toVisible() { + this.visibility = View.VISIBLE +} + +fun View.hideKeyboard() { + val imm: InputMethodManager? = getSystemService(context, InputMethodManager::class.java) + imm?.hideSoftInputFromWindow(windowToken, 0) +} \ No newline at end of file diff --git a/app/src/main/java/ir/zinutech/android/flickrsearch/core/util/AutoClearedValue.kt b/app/src/main/java/ir/zinutech/android/flickrsearch/core/util/AutoClearedValue.kt new file mode 100644 index 0000000..b552444 --- /dev/null +++ b/app/src/main/java/ir/zinutech/android/flickrsearch/core/util/AutoClearedValue.kt @@ -0,0 +1,45 @@ +package ir.zinutech.android.flickrsearch.core.util + +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * A lazy property that gets cleaned up when the fragment's view is destroyed. + * + * Accessing this variable while the fragment's view is destroyed will throw NPE. + */ +class AutoClearedValue(val fragment: Fragment) : ReadWriteProperty { + private var _value: T? = null + + init { + fragment.lifecycle.addObserver(object: DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> + viewLifecycleOwner?.lifecycle?.addObserver(object: DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + _value = null + } + }) + } + } + }) + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + return _value ?: throw IllegalStateException( + "should never call auto-cleared-value get when it might not be available" + ) + } + + override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { + _value = value + } +} + +/** + * Creates an [AutoClearedValue] associated with this fragment. + */ +fun Fragment.autoCleared() = AutoClearedValue(this) \ No newline at end of file diff --git a/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchAdapter.kt b/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchAdapter.kt new file mode 100644 index 0000000..183d99a --- /dev/null +++ b/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchAdapter.kt @@ -0,0 +1,53 @@ +package ir.zinutech.android.flickrsearch.features.search + +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.load +import ir.zinutech.android.flickrsearch.R +import ir.zinutech.android.flickrsearch.core.extensions.inflater +import ir.zinutech.android.flickrsearch.databinding.ItemSearchResultLayoutBinding +import ir.zinutech.android.flickrsearch.domain.features.search.models.FlickrPhoto + +class SearchAdapter : ListAdapter(DIFF_UTIL) { + + companion object { + private val DIFF_UTIL = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FlickrPhoto, newItem: FlickrPhoto): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: FlickrPhoto, newItem: FlickrPhoto): Boolean { + return oldItem.title == newItem.title && + oldItem.url == newItem.url + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder.create(parent) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class ViewHolder private constructor(val binding: ItemSearchResultLayoutBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(flickrPhoto: FlickrPhoto) { + binding.run { + photo = flickrPhoto + executePendingBindings() + } + } + + companion object { + fun create(parent: ViewGroup): ViewHolder { + val binding = ItemSearchResultLayoutBinding.inflate(parent.inflater(), parent, false) + return ViewHolder(binding = binding) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchFragment.kt b/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchFragment.kt index 7c2856f..98ffea8 100644 --- a/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchFragment.kt +++ b/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchFragment.kt @@ -1,25 +1,175 @@ package ir.zinutech.android.flickrsearch.features.search import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater import android.view.View +import androidx.activity.addCallback +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import by.kirich1409.viewbindingdelegate.viewBinding +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import ir.zinutech.android.flickrsearch.R +import ir.zinutech.android.flickrsearch.core.extensions.CloseKeyboardScrollListener +import ir.zinutech.android.flickrsearch.core.extensions.toGone +import ir.zinutech.android.flickrsearch.core.extensions.toInvisible +import ir.zinutech.android.flickrsearch.core.extensions.toVisible +import ir.zinutech.android.flickrsearch.core.util.autoCleared +import ir.zinutech.android.flickrsearch.databinding.FragmentSearchLayoutBinding import timber.log.Timber @AndroidEntryPoint class SearchFragment : Fragment(R.layout.fragment_search_layout) { + private val searchViewModel: SearchViewModel by viewModels() + private val viewBinding: FragmentSearchLayoutBinding by viewBinding() + private var searchResultAdapter by autoCleared() + private var keyboardCloseListener by autoCleared() + private var onSearchQueryTextListener: SearchView.OnQueryTextListener? = null + private var errorSnackBar: Snackbar? = null + private var searchView: SearchView? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + activity?.onBackPressedDispatcher?.addCallback(this, enabled = true) { + if (!isSearchViewOpen()) { + isEnabled = false + activity?.onBackPressed() + return@addCallback + } + collapseSearchView() + } + } + + private fun collapseSearchView() { + searchView?.isIconified = true + } + + private fun isSearchViewOpen() = searchView?.isIconified == false + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_search, menu) + searchView = menu.findItem(R.id.action_search)?.actionView as? SearchView + setupSearchMenuItem() + } + + override fun onDestroyOptionsMenu() { + super.onDestroyOptionsMenu() + onSearchQueryTextListener = null + searchView = null + } + + private fun setupSearchMenuItem() { + searchView ?: return + onSearchQueryTextListener = createSearchQueryTextListener() + searchView?.setOnQueryTextListener(onSearchQueryTextListener) + } + + private fun createSearchQueryTextListener(): SearchView.OnQueryTextListener { + return object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + searchViewModel.onQueryChanged(query) + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + searchViewModel.onQueryChanged(newText) + return true + } + } + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - Timber.d("onViewCreated()") - searchViewModel.uiState.observe(viewLifecycleOwner, { - Timber.d("uiState.observe(), uiState:[%s]", it) - }) + setupUi() + setupObservers() + } + + private fun setupUi() { + viewBinding.searchFragmentResultRv.apply { + keyboardCloseListener = CloseKeyboardScrollListener() + addOnScrollListener(keyboardCloseListener) + adapter = SearchAdapter().also { + searchResultAdapter = it + } + } + } - searchViewModel.onQueryChanged("hack") + private fun setupObservers() { + searchViewModel.uiState.observe(viewLifecycleOwner, ::bindUiState) + } + + private fun bindUiState(uiState: SearchViewModel.SearchUiState?) { + Timber.d("uiState.observe(), uiState:[%s]", uiState) + viewBinding.searchFragmentResultPb.isVisible = uiState == SearchViewModel.SearchUiState.Loading + dismissErrorSnackBar() + when (uiState) { + SearchViewModel.SearchUiState.Loading -> { + showLoadingUi() + } + is SearchViewModel.SearchUiState.Success -> { + showSearchResultUi(uiState) + } + + is SearchViewModel.SearchUiState.Error -> { + showErrorUi(uiState) + } + SearchViewModel.SearchUiState.Idle -> { + showIdleUi() + } + SearchViewModel.SearchUiState.EmptyResult -> { + showEmptyResultUi() + } + } + + } + + private fun showEmptyResultUi() { + viewBinding.searchFragmentSearchGuideTv.toVisible() + viewBinding.searchFragmentResultRv.toInvisible() + viewBinding.searchFragmentResultPb.toGone() + showSnackBar(getString(R.string.search_has_no_result)) + } + + private fun showIdleUi() { + viewBinding.searchFragmentSearchGuideTv.toVisible() + viewBinding.searchFragmentResultRv.toInvisible() + viewBinding.searchFragmentResultPb.toGone() + } + + private fun showErrorUi(uiState: SearchViewModel.SearchUiState.Error) { + val message = uiState.exception.localizedMessage ?: getString(R.string.something_went_wrong) + showSnackBar(message) + } + + private fun showSnackBar(message: CharSequence) { + errorSnackBar = Snackbar.make(viewBinding.root, message, Snackbar.LENGTH_INDEFINITE) + errorSnackBar?.show() + } + + private fun dismissErrorSnackBar() { + errorSnackBar?.dismiss() + errorSnackBar = null + } + + override fun onDestroyView() { + super.onDestroyView() + dismissErrorSnackBar() + } + + private fun showSearchResultUi(uiState: SearchViewModel.SearchUiState.Success) { + viewBinding.searchFragmentSearchGuideTv.toGone() + viewBinding.searchFragmentResultRv.toVisible() + searchResultAdapter.submitList(uiState.photos) + } + private fun showLoadingUi() { + viewBinding.searchFragmentSearchGuideTv.toGone() + viewBinding.searchFragmentResultRv.toInvisible() } } \ No newline at end of file diff --git a/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchViewModel.kt b/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchViewModel.kt index ca1b8d1..e6f00a4 100644 --- a/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchViewModel.kt +++ b/app/src/main/java/ir/zinutech/android/flickrsearch/features/search/SearchViewModel.kt @@ -32,12 +32,20 @@ class SearchViewModel @Inject constructor( searchQuery.debounce(DEBOUNCE_TIME_IN_MILLIS) .collectLatest { query -> Timber.d("collectLatest(), query:[%s]", query) + if (query.isEmpty()) { + _uiState.value = SearchUiState.Idle + return@collectLatest + } try { _uiState.value = SearchUiState.Loading val photos = withContext(ioDispatcher){ searchUseCase.invoke(query) } - _uiState.value = SearchUiState.Success(photos) + if (photos.isEmpty()) { + _uiState.value = SearchUiState.EmptyResult + } else { + _uiState.value = SearchUiState.Success(photos) + } } catch (e: Exception) { _uiState.value = SearchUiState.Error(e) } @@ -45,13 +53,16 @@ class SearchViewModel @Inject constructor( } } - fun onQueryChanged(query: String) { + fun onQueryChanged(query: String?) { + query ?: return searchQuery.value = query } sealed class SearchUiState { object Loading : SearchUiState() + object Idle : SearchUiState() data class Success(val photos: List) : SearchUiState() + object EmptyResult : SearchUiState() data class Error(val exception: Throwable) : SearchUiState() } diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml new file mode 100644 index 0000000..e2dd96c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_search_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 760e5f0..d9ab2db 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -8,8 +8,8 @@ - + - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_result_layout.xml b/app/src/main/res/layout/item_search_result_layout.xml new file mode 100644 index 0000000..f209234 --- /dev/null +++ b/app/src/main/res/layout/item_search_result_layout.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/menu_search.xml new file mode 100644 index 0000000..8f5daff --- /dev/null +++ b/app/src/main/res/menu/menu_search.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8b679d..bac39d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,7 @@ FlickrSearch + Search + Press on search button in the toolbar above to start searching + Something went wrong :( + Your search did not have any result :( \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..f2a9a4d --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 963c430..df8aca2 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -12,6 +12,7 @@ object Dependencies { const val material = "com.google.android.material:material:${Versions.material}" const val constraintLayout = "androidx.constraintlayout:constraintlayout:${Versions.constraintLayout}" const val viewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.viewModel}" + const val lifeCycleCommon = "androidx.lifecycle:lifecycle-common-java8:${Versions.viewModel}" } object Dagger { @@ -37,6 +38,8 @@ object Dependencies { object Misc{ const val timber = "com.jakewharton.timber:timber:${Versions.timber}" + const val viewBindingDelegate = "com.github.kirich1409:viewbindingpropertydelegate:${Versions.viewBindingDelegate}" + const val coil = "io.coil-kt:coil:${Versions.coil}" const val chuckerDebug = "com.github.chuckerteam.chucker:library:${Versions.chucker}" const val chuckerRelease = "com.github.chuckerteam.chucker:library-no-op:${Versions.chucker}" } diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 97b4694..1321db7 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -19,6 +19,8 @@ object Versions { const val moshi = "1.11.0" const val timber = "4.7.1" + const val viewBindingDelegate = "1.4.4" + const val coil = "1.1.1" const val chucker = "3.4.0" } \ No newline at end of file