implement favorite button

This commit is contained in:
Carlos Martinez
2020-02-06 10:35:54 -03:00
parent e1616a7370
commit 1fa3f8c43d
20 changed files with 236 additions and 29 deletions

View File

@@ -11,7 +11,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false" android:supportsRtl="false"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
android:hardwareAccelerated="true">
<activity <activity
android:name=".view.MainActivity" android:name=".view.MainActivity"

View File

@@ -15,12 +15,18 @@ interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveAll(entities: List<UserEntity>) fun saveAll(entities: List<UserEntity>)
@Query("UPDATE ${UserEntity.TABLE_NAME} SET isFavorite = :favorite WHERE id = :id")
fun saveFavorite(id: Int, favorite: Boolean): Int
@get:Query("SELECT * FROM ${UserEntity.TABLE_NAME}") @get:Query("SELECT * FROM ${UserEntity.TABLE_NAME}")
val all: List<UserEntity> val all: List<UserEntity>
@Query("SELECT * FROM ${UserEntity.TABLE_NAME}") @Query("SELECT * FROM ${UserEntity.TABLE_NAME} ORDER BY id ASC")
fun getAllUsers(): List<UserEntity> fun getAllUsers(): List<UserEntity>
@Query("SELECT * FROM ${UserEntity.TABLE_NAME} WHERE isFavorite = 1 ORDER BY id ASC")
fun getFavoriteUsers(): List<UserEntity>
@Query("SELECT COUNT(*) FROM ${UserEntity.TABLE_NAME}") @Query("SELECT COUNT(*) FROM ${UserEntity.TABLE_NAME}")
fun count(): Int fun count(): Int

View File

@@ -12,7 +12,8 @@ data class UserEntity(
val userName: String, val userName: String,
val email: String, val email: String,
val phone: String, val phone: String,
val website: String val website: String,
val isFavorite: Boolean = true
) { ) {
companion object { companion object {
const val TABLE_NAME = "users" const val TABLE_NAME = "users"

View File

@@ -4,4 +4,5 @@ sealed class RequestStatus {
object Ready : RequestStatus() object Ready : RequestStatus()
object Loading : RequestStatus() object Loading : RequestStatus()
object Errored : RequestStatus() object Errored : RequestStatus()
object Empty : RequestStatus()
} }

View File

@@ -11,3 +11,5 @@ fun <T> LiveData<T>.observeNonNull(owner: LifecycleOwner, func: (T) -> Unit) {
} }
}) })
} }
fun Int.wasUpdated() = this > 0

View File

@@ -0,0 +1,3 @@
package com.hako.base.navigation
interface ShowFabButton

View File

@@ -0,0 +1,23 @@
package com.hako.base.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import com.hako.base.R
import com.hako.base.extensions.inflate
import kotlinx.android.synthetic.main.empty_overlay.view.*
class EmptyOverlay @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
init {
inflate(R.layout.empty_overlay, true)
}
fun setLabel(message: String) {
empty_overlay_label.text = message
}
}

View File

@@ -10,7 +10,7 @@ import kotlinx.android.synthetic.main.like_button.view.*
private const val LIKE_MIN_FRAME = 0 private const val LIKE_MIN_FRAME = 0
private const val LIKE_MAX_FRAME = 28 private const val LIKE_MAX_FRAME = 28
private const val LIKE_ANIM_SPEED = 1f private const val LIKE_ANIM_SPEED = 1f
private const val DISLIKE_MIN_FRAME = 28 private const val DISLIKE_MIN_FRAME = 29
private const val DISLIKE_MAX_FRAME = 70 private const val DISLIKE_MAX_FRAME = 70
private const val DISLIKE_ANIM_SPEED = 2f private const val DISLIKE_ANIM_SPEED = 2f
@@ -29,16 +29,24 @@ class LikeButton @JvmOverloads constructor(
} }
fun dislike() { fun dislike() {
like_button_animation_view.frame = LIKE_MIN_FRAME like_button_animation_view.frame = DISLIKE_MAX_FRAME
} }
fun playLike() { fun play() {
if (like_button_animation_view.frame <= LIKE_MAX_FRAME){
playDislike()
} else {
playLike()
}
}
private fun playLike() {
like_button_animation_view.setMinAndMaxFrame(LIKE_MIN_FRAME, LIKE_MAX_FRAME) like_button_animation_view.setMinAndMaxFrame(LIKE_MIN_FRAME, LIKE_MAX_FRAME)
like_button_animation_view.speed = LIKE_ANIM_SPEED like_button_animation_view.speed = LIKE_ANIM_SPEED
like_button_animation_view.playAnimation() like_button_animation_view.playAnimation()
} }
fun playDislike() { private fun playDislike() {
like_button_animation_view.setMinAndMaxFrame(DISLIKE_MIN_FRAME, DISLIKE_MAX_FRAME) like_button_animation_view.setMinAndMaxFrame(DISLIKE_MIN_FRAME, DISLIKE_MAX_FRAME)
like_button_animation_view.speed = DISLIKE_ANIM_SPEED like_button_animation_view.speed = DISLIKE_ANIM_SPEED
like_button_animation_view.playAnimation() like_button_animation_view.playAnimation()

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@color/soft_background">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/empty_overlay_animation_view"
android:layout_width="200dp"
android:layout_height="200dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.39"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_rawRes="@raw/empty_animation" />
<TextView
android:id="@+id/empty_overlay_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="No tienes favoritos!"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="@+id/empty_overlay_animation_view"
app:layout_constraintStart_toStartOf="@+id/empty_overlay_animation_view"
app:layout_constraintTop_toBottomOf="@+id/empty_overlay_animation_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -6,7 +6,7 @@
android:background="@color/soft_background"> android:background="@color/soft_background">
<com.airbnb.lottie.LottieAnimationView <com.airbnb.lottie.LottieAnimationView
android:id="@+id/loading_overlay_animation_view" android:id="@+id/network_error_overlay_animation_view"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:scaleType="centerInside" android:scaleType="centerInside"
@@ -20,14 +20,14 @@
app:lottie_rawRes="@raw/network_animation" /> app:lottie_rawRes="@raw/network_animation" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/network_error_overlay_label"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="Network error!" android:text="Network error!"
android:textSize="24sp" android:textSize="24sp"
app:layout_constraintEnd_toEndOf="@+id/loading_overlay_animation_view" app:layout_constraintEnd_toEndOf="@+id/network_error_overlay_animation_view"
app:layout_constraintStart_toStartOf="@+id/loading_overlay_animation_view" app:layout_constraintStart_toStartOf="@+id/network_error_overlay_animation_view"
app:layout_constraintTop_toBottomOf="@+id/loading_overlay_animation_view" /> app:layout_constraintTop_toBottomOf="@+id/network_error_overlay_animation_view" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,9 @@ package com.hako.userlist.di
import com.hako.base.domain.network.RemoteClient import com.hako.base.domain.network.RemoteClient
import com.hako.userlist.domain.datasource.UserlistRemoteApi import com.hako.userlist.domain.datasource.UserlistRemoteApi
import com.hako.userlist.domain.usecase.GetFavoriteUsers
import com.hako.userlist.domain.usecase.GetUsers import com.hako.userlist.domain.usecase.GetUsers
import com.hako.userlist.domain.usecase.SetFavoriteStatus
import com.hako.userlist.viewmodel.UserlistViewmodel import com.hako.userlist.viewmodel.UserlistViewmodel
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
@@ -10,6 +12,8 @@ import org.koin.dsl.module
val userlistModules = module { val userlistModules = module {
factory { get<RemoteClient>().getClient(UserlistRemoteApi::class.java) } factory { get<RemoteClient>().getClient(UserlistRemoteApi::class.java) }
factory { GetUsers(get()) } factory { GetUsers(get()) }
factory { GetFavoriteUsers(get()) }
factory { SetFavoriteStatus(get()) }
viewModel { UserlistViewmodel() } viewModel { UserlistViewmodel() }
} }

View File

@@ -0,0 +1,29 @@
package com.hako.userlist.domain.usecase
import com.hako.base.domain.database.dao.UserDao
import com.hako.userlist.model.UserViewable
import com.hako.userlist.model.toUserViewable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
class GetFavoriteUsers(private val dao: UserDao) {
fun execute(
onSuccess: (List<UserViewable>) -> Unit,
onEmpty: () -> Unit,
onError: (Throwable) -> Unit
) {
Single.fromCallable { dao.getFavoriteUsers() }
.subscribeOn(Schedulers.io())
.doOnSuccess { dbUsers ->
if (dbUsers.isEmpty()) {
onEmpty()
} else {
onSuccess(dbUsers.map { it.toUserViewable() })
}
}
.doOnError { onError(it) }
.onErrorReturn { emptyList() }
.subscribe()
}
}

View File

@@ -17,7 +17,8 @@ class GetUsers(private val dao: UserDao) : KoinComponent {
fun execute( fun execute(
onSuccess: (List<UserViewable>) -> Unit, onSuccess: (List<UserViewable>) -> Unit,
onError: (Throwable) -> Unit, onError: (Throwable) -> Unit,
onLoading: () -> Unit onLoading: () -> Unit,
onEmpty: () -> Unit
) { ) {
Single.fromCallable { dao.getAllUsers() } Single.fromCallable { dao.getAllUsers() }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
@@ -26,9 +27,13 @@ class GetUsers(private val dao: UserDao) : KoinComponent {
if (dbUsers.isEmpty()) { if (dbUsers.isEmpty()) {
api.getUsers() api.getUsers()
.doOnSuccess { .doOnSuccess {
if (it.isEmpty()) {
onEmpty()
} else {
dao.saveAll(it.map { user -> user.toUserEntity() }) dao.saveAll(it.map { user -> user.toUserEntity() })
onSuccess(dao.getAllUsers().map { user -> user.toUserViewable() }) onSuccess(dao.getAllUsers().map { user -> user.toUserViewable() })
} }
}
.doOnSubscribe { onLoading() } .doOnSubscribe { onLoading() }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe({}, { onError(it) }) .subscribe({}, { onError(it) })

View File

@@ -0,0 +1,26 @@
package com.hako.userlist.domain.usecase
import com.hako.base.domain.database.dao.UserDao
import com.hako.base.extensions.wasUpdated
import com.hako.userlist.domain.datasource.UserlistRemoteApi
import com.hako.userlist.model.UserViewable
import com.hako.userlist.model.toUserEntity
import com.hako.userlist.model.toUserViewable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import org.koin.core.KoinComponent
import org.koin.core.get
class SetFavoriteStatus(private val dao: UserDao) {
fun execute(
userId: Int,
favoriteStatus: Boolean,
onError: (userId: Int) -> Unit
) {
Single.fromCallable { dao.saveFavorite(userId, favoriteStatus) }
.subscribeOn(Schedulers.io())
.doOnError { onError(userId) }
.subscribe()
}
}

View File

@@ -12,6 +12,7 @@ import com.hako.base.extensions.observeNonNull
import com.hako.base.extensions.toast import com.hako.base.extensions.toast
import com.hako.base.extensions.visible import com.hako.base.extensions.visible
import com.hako.base.navigation.NavigationRouter import com.hako.base.navigation.NavigationRouter
import com.hako.base.navigation.ShowFabButton
import com.hako.userlist.model.UserViewable import com.hako.userlist.model.UserViewable
import com.hako.userlist.viewmodel.UserlistViewmodel import com.hako.userlist.viewmodel.UserlistViewmodel
import com.hako.userlist.widget.UserlistAdapter import com.hako.userlist.widget.UserlistAdapter
@@ -22,7 +23,9 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber import timber.log.Timber
class UserlistFragment : Fragment() { const val ALBUMLIST_FRAGMENT_BUNDLE_FAVORITES = "ALBUMLIST_FRAGMENT_BUNDLE_FAVORITES"
class UserlistFragment : Fragment(), ShowFabButton {
private val viewModel: UserlistViewmodel by viewModel() private val viewModel: UserlistViewmodel by viewModel()
private val listAdapter by lazy { UserlistAdapter() } private val listAdapter by lazy { UserlistAdapter() }
@@ -40,7 +43,7 @@ class UserlistFragment : Fragment() {
} }
private fun setObservers() { private fun setObservers() {
viewModel.data.observeNonNull(this) { viewModel.userList.observeNonNull(this) {
it.either(::handleFetchError, ::handleFetchSuccess) it.either(::handleFetchError, ::handleFetchSuccess)
} }
@@ -49,17 +52,29 @@ class UserlistFragment : Fragment() {
RequestStatus.Ready -> { RequestStatus.Ready -> {
fragment_userlist_error_overlay.gone() fragment_userlist_error_overlay.gone()
fragment_userlist_loading_overlay.gone() fragment_userlist_loading_overlay.gone()
fragment_userlist_empty_overlay.gone()
} }
RequestStatus.Loading -> { RequestStatus.Loading -> {
fragment_userlist_error_overlay.gone() fragment_userlist_error_overlay.gone()
fragment_userlist_loading_overlay.visible() fragment_userlist_loading_overlay.visible()
fragment_userlist_empty_overlay.gone()
} }
RequestStatus.Errored -> { RequestStatus.Errored -> {
fragment_userlist_error_overlay.visible() fragment_userlist_error_overlay.visible()
fragment_userlist_loading_overlay.gone() fragment_userlist_loading_overlay.gone()
fragment_userlist_empty_overlay.gone()
}
RequestStatus.Empty -> {
fragment_userlist_error_overlay.gone()
fragment_userlist_loading_overlay.gone()
fragment_userlist_empty_overlay.visible()
} }
} }
} }
viewModel.emptyMessage.observeNonNull(this) {
fragment_userlist_empty_overlay.setLabel(it)
}
} }
private fun handleFetchError(throwable: Throwable) { private fun handleFetchError(throwable: Throwable) {
@@ -79,7 +94,7 @@ class UserlistFragment : Fragment() {
} }
onFavoriteClick = { onFavoriteClick = {
context.toast(it.userName) viewModel.updateUserFavoriteStatus(it.id, !it.isFavorite)
} }
} }
} }

View File

@@ -19,10 +19,10 @@ data class UserViewable(
val id: Int, val id: Int,
val realName: String, val realName: String,
val userName: String, val userName: String,
var isFavorite: Boolean = false var isFavorite: Boolean
) )
fun User.toUserEntity() = UserEntity(this.id, this.realName, this.userName, this.email, this.phone, this.website) fun User.toUserEntity() = UserEntity(this.id, this.realName, this.userName, this.email, this.phone, this.website)
fun UserEntity.toUserViewable() = UserViewable(this.id, this.realName, this.userName) fun UserEntity.toUserViewable() = UserViewable(this.id, this.realName, this.userName, this.isFavorite)

View File

@@ -3,34 +3,65 @@ package com.hako.userlist.viewmodel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.hako.base.domain.network.RequestStatus 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.base.domain.Either
import com.hako.base.domain.network.RequestStatus.*
import com.hako.userlist.domain.usecase.GetFavoriteUsers
import com.hako.userlist.domain.usecase.GetUsers import com.hako.userlist.domain.usecase.GetUsers
import com.hako.userlist.domain.usecase.SetFavoriteStatus
import com.hako.userlist.model.UserViewable import com.hako.userlist.model.UserViewable
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.get import org.koin.core.get
class UserlistViewmodel : ViewModel(), KoinComponent { class UserlistViewmodel : ViewModel(), KoinComponent {
val data = MutableLiveData<Either<Throwable, List<UserViewable>>>() val userList = MutableLiveData<Either<Throwable, List<UserViewable>>>()
val favoriteError = MutableLiveData<Int>()
val emptyMessage = MutableLiveData<String>()
val requestStatus = MutableLiveData<RequestStatus>() val requestStatus = MutableLiveData<RequestStatus>()
private val getUsers: GetUsers = get() private val getUsers: GetUsers = get()
private val getFavoriteUsers: GetFavoriteUsers = get()
private val setFavoriteStatus: SetFavoriteStatus = get()
fun fetchUsers() { fun fetchUsers() {
getUsers.execute( getUsers.execute(
onSuccess = { onSuccess = {
requestStatus.postValue(Ready) requestStatus.postValue(Ready)
data.postValue(Either.Right(it)) userList.postValue(Either.Right(it))
}, },
onLoading = { onLoading = {
requestStatus.postValue(Loading) requestStatus.postValue(Loading)
}, },
onError = { onError = {
requestStatus.postValue(Errored) requestStatus.postValue(Errored)
data.postValue(Either.Left(it)) userList.postValue(Either.Left(it))
},
onEmpty = {
requestStatus.postValue(Empty)
emptyMessage.postValue("No se encontró ningún usuario")
})
}
fun fetchFavoriteUsers() {
getFavoriteUsers.execute(
onSuccess = {
requestStatus.postValue(Ready)
userList.postValue(Either.Right(it))
},
onError = {
requestStatus.postValue(Errored)
userList.postValue(Either.Left(it))
},
onEmpty = {
requestStatus.postValue(Empty)
emptyMessage.postValue("No tienes favoritos!")
})
}
fun updateUserFavoriteStatus(userId: Int, status: Boolean) {
setFavoriteStatus.execute(userId, status,
onError = {
favoriteError.postValue(it)
}) })
} }
} }

View File

@@ -44,18 +44,26 @@ class UserlistAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
} }
class UserViewHolder(private val view: View, class UserViewHolder(
private val view: View,
private val onItemClick: (UserViewable) -> Unit, private val onItemClick: (UserViewable) -> Unit,
private val onFavoriteClick: (UserViewable) -> Unit) : private val onFavoriteClick: (UserViewable) -> Unit
) :
RecyclerView.ViewHolder(view) { RecyclerView.ViewHolder(view) {
fun bind(user: UserViewable) = with(view) { fun bind(user: UserViewable) = with(view) {
item_user_card_real_name.text = user.realName item_user_card_real_name.text = user.realName
item_user_card_user_name.text = user.userName item_user_card_user_name.text = user.userName
if (user.isFavorite) {
item_user_card_like_button.like()
} else {
item_user_card_like_button.dislike()
}
item_user_card_container.setOnClickListener { item_user_card_container.setOnClickListener {
onItemClick(user) onItemClick(user)
} }
item_user_card_like_button.setOnClickListener { item_user_card_like_button.setOnClickListener {
item_user_card_like_button.play()
onFavoriteClick(user) onFavoriteClick(user)
} }
} }

View File

@@ -35,4 +35,14 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<com.hako.base.widgets.EmptyOverlay
android:id="@+id/fragment_userlist_empty_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> </androidx.constraintlayout.widget.ConstraintLayout>