Merge pull request #6 from hakodeveloper/feature/photolist

Photolist Implementation
This commit is contained in:
Carlos Martinez
2020-02-04 23:21:36 -03:00
committed by GitHub
35 changed files with 444 additions and 58 deletions

View File

@@ -3,7 +3,7 @@ package com.hako.albumlist.domain.usecase
import com.hako.albumlist.domain.datasource.AlbumlistRemoteApi
import com.hako.albumlist.model.AlbumViewable
import com.hako.albumlist.model.toAlbumEntity
import com.hako.albumlist.model.toUserViewable
import com.hako.albumlist.model.toAlbumViewable
import com.hako.base.domain.database.dao.AlbumDao
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
@@ -28,13 +28,13 @@ class GetAlbum(private val dao: AlbumDao) : KoinComponent {
api.getAlbums(userId)
.doOnSuccess {
dao.saveAll(it.map { album -> album.toAlbumEntity() })
onSuccess(dao.getAlbums(userId).map { album -> album.toUserViewable() })
onSuccess(dao.getAlbums(userId).map { album -> album.toAlbumViewable() })
}
.doOnSubscribe { onLoading() }
.subscribeOn(Schedulers.io())
.subscribe({}, { onError(it) })
} else {
onSuccess(dbAlbum.map { it.toUserViewable() })
onSuccess(dbAlbum.map { it.toAlbumViewable() })
}
}
.subscribe()

View File

@@ -20,5 +20,5 @@ data class AlbumViewable(
fun Album.toAlbumEntity() = AlbumEntity(this.id, this.userId, this.title)
fun AlbumEntity.toUserViewable() = AlbumViewable(this.id, this.userId, this.title)
fun AlbumEntity.toAlbumViewable() = AlbumViewable(this.id, this.userId, this.title)

View File

@@ -17,10 +17,10 @@ class AlbumlistViewmodel : ViewModel(), KoinComponent {
val data = MutableLiveData<Either<Throwable, List<AlbumViewable>>>()
val requestStatus = MutableLiveData<RequestStatus>()
private val getUsers: GetAlbum = get()
private val getAlbum: GetAlbum = get()
fun fetchAlbums(userId: Int) {
getUsers.execute(
getAlbum.execute(
userId,
onSuccess = {
requestStatus.postValue(Ready)

View File

@@ -20,7 +20,7 @@ class AlbumlistAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onItemClick: (AlbumViewable) -> Unit = { }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
UserViewHolder(
AlbumViewHolder(
LayoutInflater
.from(parent.context)
.inflate(R.layout.item_album_card, parent, false),
@@ -37,12 +37,12 @@ class AlbumlistAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onBindViewHolder(viewholder: RecyclerView.ViewHolder, position: Int) =
when (viewholder) {
is UserViewHolder -> viewholder.bind(items[position])
is AlbumViewHolder -> viewholder.bind(items[position])
else -> throw NoWhenBranchMatchedException("Undefined viewholder")
}
}
class UserViewHolder(private val view: View,
class AlbumViewHolder(private val view: View,
private val onItemClick: (AlbumViewable) -> Unit) :
RecyclerView.ViewHolder(view) {

View File

@@ -14,12 +14,15 @@
<TextView
android:id="@+id/item_album_card_album_name"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
android:ellipsize="end"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Album name" />

View File

@@ -9,6 +9,6 @@
android:id="@+id/albumlistFragment"
android:name="com.hako.albumlist.feature.AlbumlistFragment"
tools:layout="@layout/fragment_albumlist"
android:label="AlbumListFragment" />
android:label="AlbumlistFragment" />
</navigation>

View File

@@ -50,4 +50,5 @@ dependencies {
implementation project(":base")
implementation project(":userlist")
implementation project(":albumlist")
implementation project(":photolist")
}

View File

@@ -4,6 +4,7 @@ import android.app.Application
import com.hako.albumlist.di.albumListModules
import com.hako.userlist.di.userlistModules
import com.hako.friendlists.di.appModules
import com.hako.photolist.di.photoListModules
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import timber.log.Timber
@@ -28,7 +29,8 @@ class MainApplication : Application() {
listOf(
appModules,
userlistModules,
albumListModules
albumListModules,
photoListModules
)
)
}

View File

@@ -4,6 +4,7 @@ import androidx.room.Room
import com.hako.base.domain.database.DatabaseClient
import com.hako.base.domain.network.RemoteClient
import com.hako.friendlists.BuildConfig
import com.squareup.picasso.Picasso
import org.koin.dsl.module
val appModules = module {
@@ -15,4 +16,7 @@ val appModules = module {
// Retrofit
single { RemoteClient(BuildConfig.BASE_ENDPOINT) }
// Picasso
single { Picasso.get() }
}

View File

@@ -2,10 +2,12 @@
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main_navigation"
app:startDestination="@id/userlist_navigation">
app:startDestination="@id/photolist_navigation">
<include app:graph="@navigation/userlist_navigation" />
<include app:graph="@navigation/albumlist_navigation" />
<include app:graph="@navigation/photolist_navigation" />
</navigation>

View File

@@ -51,6 +51,7 @@ dependencies {
api deps.okhttp_logging_interceptor
api deps.timber
api deps.lottie
api deps.picasso
//Testing
api deps.testing.junit
api deps.testing.koin

View File

@@ -1,24 +0,0 @@
package com.hako.base
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.hako.base.test", appContext.packageName)
}
}

View File

@@ -18,6 +18,9 @@ interface PhotoDao {
@get:Query("SELECT * FROM ${PhotoEntity.TABLE_NAME}")
val all: List<PhotoEntity>
@Query("SELECT * FROM ${PhotoEntity.TABLE_NAME} WHERE albumId = :albumId ORDER BY id ASC")
fun getPhotos(albumId: Int): List<PhotoEntity>
@Query("SELECT COUNT(*) FROM ${PhotoEntity.TABLE_NAME}")
fun count(): Int

View File

@@ -7,6 +7,9 @@ import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import timber.log.Timber
import java.util.concurrent.TimeUnit
private const val TIMEOUT_IN_SECONDS = 60L
class RemoteClient(endpoint: String) {
private val logger = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger {
@@ -17,6 +20,8 @@ class RemoteClient(endpoint: String) {
private val client = OkHttpClient.Builder()
.addInterceptor(logger)
.readTimeout(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS)
.connectTimeout(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS)
.build()
private val retrofit: Retrofit = Retrofit.Builder()

View File

@@ -1,17 +0,0 @@
package com.hako.base
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -20,6 +20,7 @@ versions.test = "1.2.0"
versions.test_ext = "1.1.1"
versions.espresso = "3.2.0"
versions.lottie = "3.3.1"
versions.picasso = "2.71828"
def deps = [:]
@@ -77,5 +78,6 @@ deps.testing = testing
deps.okhttp_logging_interceptor = "com.squareup.okhttp3:logging-interceptor:$versions.okhttp_logging_interceptor"
deps.timber = "com.jakewharton.timber:timber:$versions.timber"
deps.lottie = "com.airbnb.android:lottie:$versions.lottie"
deps.picasso = "com.squareup.picasso:picasso:$versions.picasso"
ext.deps = deps

1
photolist/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

6
photolist/build.gradle Normal file
View File

@@ -0,0 +1,6 @@
apply plugin: 'com.android.library'
apply from: '../core.gradle'
dependencies {
implementation project(':base')
}

View File

21
photolist/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.hako.photolist" />

View File

@@ -0,0 +1,15 @@
package com.hako.photolist.di
import com.hako.base.domain.network.RemoteClient
import com.hako.photolist.domain.datasource.PhotolistRemoteApi
import com.hako.photolist.domain.usecase.GetPhoto
import com.hako.photolist.viewmodel.PhotolistViewmodel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val photoListModules = module {
factory { get<RemoteClient>().getClient(PhotolistRemoteApi::class.java) }
factory { GetPhoto(get()) }
viewModel { PhotolistViewmodel() }
}

View File

@@ -0,0 +1,14 @@
package com.hako.photolist.domain.datasource
import com.hako.photolist.model.Photo
import io.reactivex.Single
import retrofit2.http.GET
import retrofit2.http.Query
interface PhotolistRemoteApi {
@GET("/photos")
fun getPhotos(
@Query("albumId") albumId: Int
): Single<List<Photo>>
}

View File

@@ -0,0 +1,40 @@
package com.hako.photolist.domain.usecase
import com.hako.base.domain.database.dao.PhotoDao
import com.hako.photolist.domain.datasource.PhotolistRemoteApi
import com.hako.photolist.model.*
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import org.koin.core.KoinComponent
import org.koin.core.get
class GetPhoto(private val dao: PhotoDao) : KoinComponent {
private val api: PhotolistRemoteApi = get()
fun execute(
albumId: Int,
onSuccess: (List<PhotoViewable>) -> Unit,
onError: (Throwable) -> Unit,
onLoading: () -> Unit
) {
Single.fromCallable { dao.getPhotos(albumId) }
.subscribeOn(Schedulers.io())
.doOnError { onError(it) }
.doOnSuccess { dbAlbum ->
if (dbAlbum.isEmpty() || dbAlbum.count() == 0) {
api.getPhotos(albumId)
.doOnSuccess {
dao.saveAll(it.map { album -> album.toPhotoEntity() })
onSuccess(dao.getPhotos(albumId).map { album -> album.toPhotoViewable() })
}
.doOnSubscribe { onLoading() }
.subscribeOn(Schedulers.io())
.subscribe({}, { onError(it) })
} else {
onSuccess(dbAlbum.map { it.toPhotoViewable() })
}
}
.subscribe()
}
}

View File

@@ -0,0 +1,75 @@
package com.hako.photolist.feature
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.hako.base.domain.network.RequestStatus
import com.hako.base.extensions.gone
import com.hako.base.extensions.observeNonNull
import com.hako.base.extensions.visible
import com.hako.photolist.R
import com.hako.photolist.model.PhotoViewable
import com.hako.photolist.viewmodel.PhotolistViewmodel
import com.hako.photolist.widget.PhotolistAdapter
import kotlinx.android.synthetic.main.fragment_photolist.*
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
class PhotolistFragment : Fragment() {
private val viewModel: PhotolistViewmodel by viewModel()
private val listAdapter by lazy { PhotolistAdapter() }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_photolist, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setRecycler()
setObservers()
// TODO: Get album by bundle
viewModel.fetchPhotos(1)
}
private fun setObservers() {
viewModel.data.observeNonNull(this) {
it.either(::handleFetchError, ::handleFetchSuccess)
}
viewModel.requestStatus.observeNonNull(this) {
when (it) {
RequestStatus.Ready -> {
fragment_photolist_error_overlay.gone()
fragment_photolist_loading_overlay.gone()
}
RequestStatus.Loading -> {
fragment_photolist_error_overlay.gone()
fragment_photolist_loading_overlay.visible()
}
RequestStatus.Errored -> {
fragment_photolist_error_overlay.visible()
fragment_photolist_loading_overlay.gone()
}
}
}
}
private fun handleFetchError(throwable: Throwable) {
Timber.e(throwable)
}
private fun handleFetchSuccess(photos: List<PhotoViewable>) {
listAdapter.addAll(photos)
}
private fun setRecycler() {
fragment_photolist_recycler_container.apply {
layoutManager = LinearLayoutManager(context)
adapter = listAdapter
}
}
}

View File

@@ -0,0 +1,27 @@
package com.hako.photolist.model
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.hako.base.domain.database.entities.PhotoEntity
import kotlinx.android.parcel.Parcelize
@Parcelize
data class Photo(
@SerializedName("id") val id: Int,
@SerializedName("albumId") val albumId: Int,
@SerializedName("title") val title: String,
@SerializedName("url") val photoUrl: String,
@SerializedName("thumbnailUrl") val thumbnailUrl: String
) : Parcelable
data class PhotoViewable(
val id: Int,
val albumId: Int,
val title: String,
val photoUrl: String
)
fun Photo.toPhotoEntity() = PhotoEntity(this.id, this.albumId, this.title, this.photoUrl, this.thumbnailUrl)
fun PhotoEntity.toPhotoViewable() = PhotoViewable(this.id, this.albumId, this.title, this.photoUrl)

View File

@@ -0,0 +1,37 @@
package com.hako.photolist.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.hako.base.domain.network.RequestStatus
import com.hako.base.domain.network.RequestStatus.Ready
import com.hako.base.domain.network.RequestStatus.Loading
import com.hako.base.domain.network.RequestStatus.Errored
import com.hako.base.domain.Either
import com.hako.photolist.domain.usecase.GetPhoto
import com.hako.photolist.model.PhotoViewable
import org.koin.core.KoinComponent
import org.koin.core.get
class PhotolistViewmodel : ViewModel(), KoinComponent {
val data = MutableLiveData<Either<Throwable, List<PhotoViewable>>>()
val requestStatus = MutableLiveData<RequestStatus>()
private val getPhoto: GetPhoto = get()
fun fetchPhotos(albumId: Int) {
getPhoto.execute(
albumId,
onSuccess = {
requestStatus.postValue(Ready)
data.postValue(Either.Right(it))
},
onLoading = {
requestStatus.postValue(Loading)
},
onError = {
requestStatus.postValue(Errored)
data.postValue(Either.Left(it))
})
}
}

View File

@@ -0,0 +1,61 @@
package com.hako.photolist.widget
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.hako.base.extensions.autoNotify
import com.hako.photolist.R
import com.hako.photolist.model.PhotoViewable
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.item_photo_card.view.*
import org.koin.core.KoinComponent
import org.koin.core.inject
import kotlin.properties.Delegates
class PhotolistAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items by Delegates.observable(emptyList<PhotoViewable>()) { _, oldList, newList ->
autoNotify(oldList, newList) { old, new -> old.id == new.id }
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
PhotoViewHolder(
LayoutInflater
.from(parent.context)
.inflate(R.layout.item_photo_card, parent, false)
)
fun getItem(position: Int) = items[position]
fun addAll(list: List<PhotoViewable>) {
items = list
}
override fun getItemCount() = items.size
override fun onBindViewHolder(viewholder: RecyclerView.ViewHolder, position: Int) =
when (viewholder) {
is PhotoViewHolder -> viewholder.bind(items[position])
else -> throw NoWhenBranchMatchedException("Undefined viewholder")
}
}
class PhotoViewHolder(private val view: View) :
RecyclerView.ViewHolder(view), KoinComponent {
private val picasso: Picasso by inject()
init {
picasso.setIndicatorsEnabled(true)
}
fun bind(photo: PhotoViewable) = with(view) {
picasso.load(photo.photoUrl)
.placeholder(R.drawable.img_photo_placeholder)
.fit()
.into(item_photo_card_photo)
item_photo_card_title.text = photo.title
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:id="@+id/fragment_photolist_base">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/fragment_photolist_recycler_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.hako.base.widgets.LoadingOverlay
android:id="@+id/fragment_photolist_loading_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.hako.base.widgets.NetworkErrorOverlay
android:id="@+id/fragment_photolist_error_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/item_photo_card_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_card"
android:elevation="2dp"
android:orientation="vertical">
<ImageView
android:id="@+id/item_photo_card_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/album_photo" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item_photo_card_footer_container"
android:layout_width="match_parent"
android:layout_height="70dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/item_photo_card_photo">
<TextView
android:id="@+id/item_photo_card_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
android:ellipsize="end"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Album name" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/photolist_navigation"
app:startDestination="@id/photolistFragment">
<fragment
android:id="@+id/photolistFragment"
android:name="com.hako.photolist.feature.PhotolistFragment"
tools:layout="@layout/fragment_photolist"
android:label="PhotolistFragment" />
</navigation>

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">photolist</string>
<string name="album_photo">Album photo</string>
</resources>

View File

@@ -1,2 +1,2 @@
include ':app', ':base', ':userlist', ':albumlist'
include ':app', ':base', ':userlist', ':albumlist', ':photolist'
rootProject.name='Friendlists'

View File

@@ -3,12 +3,12 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/userlist_navigation"
app:startDestination="@id/userListFragment">
app:startDestination="@id/userlistFragment">
<fragment
android:id="@+id/userListFragment"
android:id="@+id/userlistFragment"
android:name="com.hako.userlist.feature.UserlistFragment"
tools:layout="@layout/fragment_userlist"
android:label="UserListFragment" />
android:label="UserlistFragment" />
</navigation>