diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d4c4aa9..2d9b96b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,11 @@ + + + +) \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashPagingSource.kt b/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashPagingSource.kt new file mode 100644 index 0000000..7423171 --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashPagingSource.kt @@ -0,0 +1,33 @@ +package com.codinginflow.imagesearchapp.data + +import androidx.paging.PagingSource +import com.codinginflow.imagesearchapp.api.UnsplashApi +import retrofit2.HttpException +import java.io.IOException + +private const val UNSPLASH_STARTING_PAGE_INDEX = 1 + +class UnsplashPagingSource( + private val unsplashApi: UnsplashApi, + private val query: String +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + val position = params.key ?: UNSPLASH_STARTING_PAGE_INDEX + + return try { + val response = unsplashApi.searchPhotos(query, position, params.loadSize) + val photos = response.results + + LoadResult.Page( + data = photos, + prevKey = if (position == UNSPLASH_STARTING_PAGE_INDEX) null else position - 1, + nextKey = if (photos.isEmpty()) null else position + 1 + ) + } catch (exception: IOException) { + LoadResult.Error(exception) + } catch (exception: HttpException) { + LoadResult.Error(exception) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashPhoto.kt b/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashPhoto.kt new file mode 100644 index 0000000..a8bc5c9 --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashPhoto.kt @@ -0,0 +1,30 @@ +package com.codinginflow.imagesearchapp.data + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class UnsplashPhoto( + val id: String, + val description: String?, + val urls: UnsplashPhotoUrls, + val user: UnsplashUser +) : Parcelable { + + @Parcelize + data class UnsplashPhotoUrls( + val raw: String, + val full: String, + val regular: String, + val small: String, + val thumb: String, + ) : Parcelable + + @Parcelize + data class UnsplashUser( + val name: String, + val username: String + ) : Parcelable { + val attributionUrl get() = "https://unsplash.com/$username?utm_source=ImageSearchApp&utm_medium=referral" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashRepository.kt b/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashRepository.kt new file mode 100644 index 0000000..20f92c7 --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/data/UnsplashRepository.kt @@ -0,0 +1,22 @@ +package com.codinginflow.imagesearchapp.data + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.liveData +import com.codinginflow.imagesearchapp.api.UnsplashApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UnsplashRepository @Inject constructor(private val unsplashApi: UnsplashApi) { + + fun getSearchResults(query: String) = + Pager( + config = PagingConfig( + pageSize = 20, + maxSize = 100, + enablePlaceholders = false + ), + pagingSourceFactory = { UnsplashPagingSource(unsplashApi, query) } + ).liveData +} \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/di/AppModule.kt b/app/src/main/java/com/codinginflow/imagesearchapp/di/AppModule.kt new file mode 100644 index 0000000..2916e21 --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/di/AppModule.kt @@ -0,0 +1,29 @@ +package com.codinginflow.imagesearchapp.di + +import com.codinginflow.imagesearchapp.api.UnsplashApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ApplicationComponent +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(ApplicationComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideRetrofit(): Retrofit = + Retrofit.Builder() + .baseUrl(UnsplashApi.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + @Provides + @Singleton + fun provideUnsplashApi(retrofit: Retrofit): UnsplashApi = + retrofit.create(UnsplashApi::class.java) + +} \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/GalleryFragment.kt b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/GalleryFragment.kt new file mode 100644 index 0000000..27cd649 --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/GalleryFragment.kt @@ -0,0 +1,98 @@ +package com.codinginflow.imagesearchapp.ui.gallery + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.paging.LoadState +import com.codinginflow.imagesearchapp.R +import com.codinginflow.imagesearchapp.databinding.FragmentGalleryBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class GalleryFragment : Fragment(R.layout.fragment_gallery) { + + private val viewModel by viewModels() + + private var _binding: FragmentGalleryBinding? = null + private val binding get() = _binding!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + _binding = FragmentGalleryBinding.bind(view) + + val adapter = UnsplashPhotoAdapter() + + binding.apply { + recyclerView.setHasFixedSize(true) + recyclerView.itemAnimator = null + recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( + header = UnsplashPhotoLoadStateAdapter { adapter.retry() }, + footer = UnsplashPhotoLoadStateAdapter { adapter.retry() }, + ) + buttonRetry.setOnClickListener { + adapter.retry() + } + } + + viewModel.photos.observe(viewLifecycleOwner) { + adapter.submitData(viewLifecycleOwner.lifecycle, it) + } + + adapter.addLoadStateListener { loadState -> + binding.apply { + progressBar.isVisible = loadState.source.refresh is LoadState.Loading + recyclerView.isVisible = loadState.source.refresh is LoadState.NotLoading + buttonRetry.isVisible = loadState.source.refresh is LoadState.Error + textViewError.isVisible = loadState.source.refresh is LoadState.Error + + // empty view + if (loadState.source.refresh is LoadState.NotLoading && + loadState.append.endOfPaginationReached && + adapter.itemCount < 1) { + recyclerView.isVisible = false + textViewEmpty.isVisible = true + } else { + textViewEmpty.isVisible = false + } + } + } + + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + + inflater.inflate(R.menu.menu_gallery, menu) + + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + + if (query != null) { + binding.recyclerView.scrollToPosition(0) + viewModel.searchPhotos(query) + searchView.clearFocus() + } + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + return true + } + }) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/GalleryViewModel.kt b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/GalleryViewModel.kt new file mode 100644 index 0000000..b00192d --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/GalleryViewModel.kt @@ -0,0 +1,28 @@ +package com.codinginflow.imagesearchapp.ui.gallery + +import androidx.hilt.lifecycle.ViewModelInject +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import com.codinginflow.imagesearchapp.data.UnsplashRepository + +class GalleryViewModel @ViewModelInject constructor( + private val repository: UnsplashRepository +) : ViewModel() { + + private val currentQuery = MutableLiveData(DEFAULT_QUERY) + + val photos = currentQuery.switchMap { queryString -> + repository.getSearchResults(queryString).cachedIn(viewModelScope) + } + + fun searchPhotos(query: String) { + currentQuery.value = query + } + + companion object { + private const val DEFAULT_QUERY = "cats" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/UnsplashPhotoAdapter.kt b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/UnsplashPhotoAdapter.kt new file mode 100644 index 0000000..0c410cf --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/UnsplashPhotoAdapter.kt @@ -0,0 +1,58 @@ +package com.codinginflow.imagesearchapp.ui.gallery + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.codinginflow.imagesearchapp.R +import com.codinginflow.imagesearchapp.data.UnsplashPhoto +import com.codinginflow.imagesearchapp.databinding.ItemUnsplashPhotoBinding + +class UnsplashPhotoAdapter : + PagingDataAdapter(PHOTO_COMPARATOR) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoViewHolder { + val binding = + ItemUnsplashPhotoBinding.inflate(LayoutInflater.from(parent.context), parent, false) + + return PhotoViewHolder(binding) + } + + override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) { + val currentItem = getItem(position) + + if (currentItem != null) { + holder.bind(currentItem) + } + } + + class PhotoViewHolder(private val binding: ItemUnsplashPhotoBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(photo: UnsplashPhoto) { + binding.apply { + Glide.with(itemView) + .load(photo.urls.regular) + .centerCrop() + .transition(DrawableTransitionOptions.withCrossFade()) + .error(R.drawable.ic_error) + .into(imageView) + + textViewUserName.text = photo.user.username + } + } + } + + companion object { + private val PHOTO_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UnsplashPhoto, newItem: UnsplashPhoto) = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: UnsplashPhoto, newItem: UnsplashPhoto) = + oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/UnsplashPhotoLoadStateAdapter.kt b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/UnsplashPhotoLoadStateAdapter.kt new file mode 100644 index 0000000..8f366a3 --- /dev/null +++ b/app/src/main/java/com/codinginflow/imagesearchapp/ui/gallery/UnsplashPhotoLoadStateAdapter.kt @@ -0,0 +1,45 @@ +package com.codinginflow.imagesearchapp.ui.gallery + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter +import androidx.recyclerview.widget.RecyclerView +import com.codinginflow.imagesearchapp.databinding.UnsplashPhotoLoadStateFooterBinding + +class UnsplashPhotoLoadStateAdapter(private val retry: () -> Unit) : + LoadStateAdapter() { + + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder { + val binding = UnsplashPhotoLoadStateFooterBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + + return LoadStateViewHolder(binding) + } + + override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } + + inner class LoadStateViewHolder(private val binding: UnsplashPhotoLoadStateFooterBinding) : + RecyclerView.ViewHolder(binding.root) { + + init { + binding.buttonRetry.setOnClickListener { + retry.invoke() + } + } + + fun bind(loadState: LoadState) { + binding.apply { + progressBar.isVisible = loadState is LoadState.Loading + buttonRetry.isVisible = loadState !is LoadState.Loading + textViewError.isVisible = loadState !is LoadState.Loading + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/gradient.xml b/app/src/main/res/drawable/gradient.xml new file mode 100644 index 0000000..674f6c9 --- /dev/null +++ b/app/src/main/res/drawable/gradient.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4fc2444..c928bb7 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,13 +6,12 @@ android:layout_height="match_parent" tools:context=".MainActivity"> - + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_gallery.xml b/app/src/main/res/layout/fragment_gallery.xml new file mode 100644 index 0000000..34059e8 --- /dev/null +++ b/app/src/main/res/layout/fragment_gallery.xml @@ -0,0 +1,52 @@ + + + + + + + + + +