Commit ed35351f authored by Konstantin Tskhovrebov's avatar Konstantin Tskhovrebov 🤖

Merge branch 'feature/project_members_presentation' into 'develop'

Feature/project members presentation

See merge request terrakok/gitlab-client!207
parents 723cfda4 bf8e223f
......@@ -33,6 +33,7 @@ import ru.terrakok.gitlabclient.ui.project.info.ProjectInfoFragment
import ru.terrakok.gitlabclient.ui.project.issues.ProjectIssuesContainerFragment
import ru.terrakok.gitlabclient.ui.project.issues.ProjectIssuesFragment
import ru.terrakok.gitlabclient.ui.project.labels.ProjectLabelsFragment
import ru.terrakok.gitlabclient.ui.project.members.ProjectMembersFragment
import ru.terrakok.gitlabclient.ui.project.mergerequest.ProjectMergeRequestsContainerFragment
import ru.terrakok.gitlabclient.ui.project.mergerequest.ProjectMergeRequestsFragment
import ru.terrakok.gitlabclient.ui.project.milestones.ProjectMilestonesFragment
......@@ -190,6 +191,10 @@ object Screens {
override fun getFragment() = ProjectMilestonesFragment()
}
object ProjectMembers : SupportAppScreen() {
override fun getFragment() = ProjectMembersFragment()
}
object ProjectFiles : SupportAppScreen() {
override fun getFragment() = ProjectFilesFragment()
}
......
package ru.terrakok.gitlabclient.entity
import com.google.gson.annotations.SerializedName
data class Member(
@SerializedName("id") val id: Long,
@SerializedName("username") val username: String,
@SerializedName("name") val name: String,
@SerializedName("state") val state: String?,
@SerializedName("avatar_url") val avatarUrl: String?,
@SerializedName("web_url") val webUrl: String?,
@SerializedName("expires_at") val expiresDate: String?,
@SerializedName("access_level") val accessLevel: Long
)
\ No newline at end of file
......@@ -28,6 +28,7 @@ import ru.terrakok.gitlabclient.entity.todo.TodoState
* @author Konstantin Tskhovrebov (aka terrakok). Date: 28.03.17
*/
interface GitlabApi {
companion object {
const val API_PATH = "api/v4"
// See GitLab documentation: https://docs.gitlab.com/ee/api/#pagination.
......@@ -395,4 +396,39 @@ interface GitlabApi {
@Query("state") state: TodoState = TodoState.PENDING,
@Query("per_page") pageSize: Int = 1
): Single<Result<Void>>
@GET("$API_PATH/projects/{project_id}/members")
fun getMembers(
@Path("project_id") projectId: Long,
@Query("page") page: Int,
@Query("per_page") pageSize: Int
): Single<List<Member>>
@GET("$API_PATH/projects/{project_id}/members/{member_id}")
fun getMember(
@Path("project_id") projectId: Long,
@Path("member_id") memberId: Long
): Single<Member>
@POST("$API_PATH/projects/{project_id}/members")
fun addMember(
@Path("project_id") projectId: Long,
@Field("user_id") userId: Long,
@Field("access_level") accessLevel: Long,
@Field("expires_at") expiresDate: String?
): Completable
@PUT("$API_PATH/projects/{project_id}/members/{user_id}")
fun editMember(
@Path("project_id") projectId: Long,
@Path("user_id") userId: Long,
@Field("access_level") accessLevel: Long,
@Field("expires_at") expiresDate: String?
): Completable
@DELETE("$API_PATH/projects/{project_id}/members/{user_id}")
fun deleteMember(
@Path("project_id") projectId: Long,
@Path("user_id") userId: Long
): Completable
}
\ No newline at end of file
package ru.terrakok.gitlabclient.model.interactor.members
import ru.terrakok.gitlabclient.model.repository.members.MembersRepository
import javax.inject.Inject
/**
* @author Valentin Logvinovitch (glvvl) on 27.02.19.
*/
class MembersInteractor @Inject constructor(
private val membersRepository: MembersRepository
) {
fun getMembers(projectId: Long, page: Int) =
membersRepository.getMembers(projectId, page)
fun getMember(projectId: Long, memberId: Long) =
membersRepository.getMember(projectId, memberId)
fun addMember(projectId: Long, userId: Long, accessLevel: Long) =
membersRepository.addMember(projectId, userId, accessLevel)
fun editMember(projectId: Long, userId: Long, accessLevel: Long) =
membersRepository.editMember(projectId, userId, accessLevel)
fun deleteMember(projectId: Long, userId: Long) =
membersRepository.deleteMember(projectId, userId)
}
\ No newline at end of file
package ru.terrakok.gitlabclient.model.repository.members
import io.reactivex.Completable
import io.reactivex.Single
import ru.terrakok.gitlabclient.di.DefaultPageSize
import ru.terrakok.gitlabclient.di.PrimitiveWrapper
import ru.terrakok.gitlabclient.entity.Member
import ru.terrakok.gitlabclient.model.data.server.GitlabApi
import ru.terrakok.gitlabclient.model.system.SchedulersProvider
import javax.inject.Inject
/**
* @author Valentin Logvinovitch (glvvl) on 27.02.19.
*/
class MembersRepository @Inject constructor(
private val api: GitlabApi,
private val schedulers: SchedulersProvider,
@DefaultPageSize private val defaultPageSizeWrapper: PrimitiveWrapper<Int>
) {
private val defaultPageSize = defaultPageSizeWrapper.value
fun getMembers(
projectId: Long,
page: Int,
pageSize: Int = defaultPageSize
): Single<List<Member>> =
api
.getMembers(projectId, page, pageSize)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun getMember(
projectId: Long,
memberId: Long
): Single<Member> =
api
.getMember(projectId, memberId)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun addMember(
projectId: Long,
userId: Long,
accessLevel: Long,
expiresDate: String? = null
): Completable =
api
.addMember(projectId, userId, accessLevel, expiresDate)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun editMember(
projectId: Long,
userId: Long,
accessLevel: Long,
expiresDate: String? = null
): Completable =
api
.editMember(projectId, userId, accessLevel, expiresDate)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun deleteMember(
projectId: Long,
userId: Long
): Completable =
api
.deleteMember(projectId, userId)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
}
\ No newline at end of file
package ru.terrakok.gitlabclient.presentation.project.members
import com.arellomobile.mvp.InjectViewState
import ru.terrakok.gitlabclient.Screens
import ru.terrakok.gitlabclient.di.PrimitiveWrapper
import ru.terrakok.gitlabclient.di.ProjectId
import ru.terrakok.gitlabclient.entity.Member
import ru.terrakok.gitlabclient.model.interactor.members.MembersInteractor
import ru.terrakok.gitlabclient.model.system.flow.FlowRouter
import ru.terrakok.gitlabclient.presentation.global.BasePresenter
import ru.terrakok.gitlabclient.presentation.global.ErrorHandler
import ru.terrakok.gitlabclient.presentation.global.Paginator
import javax.inject.Inject
/**
* @author Valentin Logvinovitch (glvvl) on 28.02.19.
*/
@InjectViewState
class ProjectMembersPresenter @Inject constructor(
@ProjectId projectIdWrapper: PrimitiveWrapper<Long>,
private val membersInteractor: MembersInteractor,
private val errorHandler: ErrorHandler,
private val router: FlowRouter
) : BasePresenter<ProjectMembersView>() {
private val projectId = projectIdWrapper.value
override fun onFirstViewAttach() {
super.onFirstViewAttach()
refreshMembers()
}
private val paginator = Paginator(
{ membersInteractor.getMembers(projectId, it) },
object : Paginator.ViewController<Member> {
override fun showEmptyProgress(show: Boolean) {
viewState.showEmptyProgress(show)
}
override fun showEmptyError(show: Boolean, error: Throwable?) {
if (error != null) {
errorHandler.proceed(error) { viewState.showEmptyError(show, it) }
} else {
viewState.showEmptyError(show, null)
}
}
override fun showErrorMessage(error: Throwable) {
errorHandler.proceed(error, { viewState.showMessage(it) })
}
override fun showEmptyView(show: Boolean) {
viewState.showEmptyView(show)
}
override fun showData(show: Boolean, data: List<Member>) {
viewState.showMembers(show, data)
}
override fun showRefreshProgress(show: Boolean) {
viewState.showRefreshProgress(show)
}
override fun showPageProgress(show: Boolean) {
viewState.showPageProgress(show)
}
}
)
fun onMemberClick(userId: Long) {
//TODO Member Flow(refactor this logic when Member Flow was be ready).
router.startFlow(Screens.UserFlow(userId))
}
fun refreshMembers() = paginator.refresh()
fun loadNextMembersPage() = paginator.loadNewPage()
fun onBackPressed() = router.exit()
override fun onDestroy() {
super.onDestroy()
paginator.release()
}
}
\ No newline at end of file
package ru.terrakok.gitlabclient.presentation.project.members
import com.arellomobile.mvp.MvpView
import com.arellomobile.mvp.viewstate.strategy.AddToEndSingleStrategy
import com.arellomobile.mvp.viewstate.strategy.OneExecutionStateStrategy
import com.arellomobile.mvp.viewstate.strategy.StateStrategyType
import ru.terrakok.gitlabclient.entity.Member
@StateStrategyType(AddToEndSingleStrategy::class)
interface ProjectMembersView : MvpView {
fun showRefreshProgress(show: Boolean)
fun showEmptyProgress(show: Boolean)
fun showPageProgress(show: Boolean)
fun showEmptyView(show: Boolean)
fun showEmptyError(show: Boolean, message: String?)
fun showMembers(show: Boolean, members: List<Member>)
@StateStrategyType(OneExecutionStateStrategy::class)
fun showMessage(message: String)
}
\ No newline at end of file
package ru.terrakok.gitlabclient.ui.global.list
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import kotlinx.android.synthetic.main.item_member.view.*
import ru.terrakok.gitlabclient.R
import ru.terrakok.gitlabclient.entity.Member
import ru.terrakok.gitlabclient.extension.inflate
import ru.terrakok.gitlabclient.extension.loadRoundedImage
/**
* @author Valentin Logvinovitch (glvvl) on 28.02.19.
*/
class MembersAdapterDelegate(
private val clickListener: (Long) -> Unit
) : AdapterDelegate<MutableList<Any>>() {
override fun isForViewType(items: MutableList<Any>, position: Int) =
items[position] is Member
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder =
ViewHolder(parent.inflate(R.layout.item_member))
override fun onBindViewHolder(
items: MutableList<Any>,
position: Int,
viewHolder: RecyclerView.ViewHolder,
payloads: MutableList<Any>
) = (viewHolder as ViewHolder).bind(items[position] as Member)
private inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val guestTitle = view.context.getString(R.string.members_guest)
private val reporterTitle = view.context.getString(R.string.members_reporter)
private val developerTitle = view.context.getString(R.string.members_developer)
private val maintainerTitle = view.context.getString(R.string.members_maintainer)
private val ownerTitle = view.context.getString(R.string.members_owner)
private lateinit var data: Member
init {
view.setOnClickListener { clickListener(data.id) }
}
fun bind(data: Member) {
this.data = data
with(itemView) {
avatarImageView.loadRoundedImage(data.avatarUrl)
nameTextView.text = data.name
roleTextView.text = data.accessLevel.accessToString()
}
}
private fun Long.accessToString(): String =
when (this) {
10L -> guestTitle
20L -> reporterTitle
30L -> developerTitle
40L -> maintainerTitle
50L -> ownerTitle
else -> throw IllegalArgumentException("You must provide correct value to accessLevel")
}
}
}
\ No newline at end of file
......@@ -38,16 +38,18 @@ class ProjectInfoContainerFragment : BaseFragment() {
0 -> Screens.ProjectInfo.fragment
1 -> Screens.ProjectEvents.fragment
2 -> Screens.ProjectLabels.fragment
else -> Screens.ProjectMilestones.fragment
3 -> Screens.ProjectMilestones.fragment
else -> Screens.ProjectMembers.fragment
}
override fun getCount() = 4
override fun getCount() = 5
override fun getPageTitle(position: Int) = when (position) {
0 -> getString(R.string.project_info)
1 -> getString(R.string.project_events)
2 -> getString(R.string.project_labels)
3 -> getString(R.string.project_milestones)
4 -> getString(R.string.project_members)
else -> null
}
}
......
package ru.terrakok.gitlabclient.ui.project.members
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import ru.terrakok.gitlabclient.entity.Member
import ru.terrakok.gitlabclient.ui.global.list.MembersAdapterDelegate
import ru.terrakok.gitlabclient.ui.global.list.ProgressAdapterDelegate
import ru.terrakok.gitlabclient.ui.global.list.ProgressItem
/*
* @author Valentin Logvinovitch (glvvl) on 28.02.19.
*/
class MembersAdapter(
clickListener: (Long) -> Unit,
private val nextPageListener: () -> Unit
) : ListDelegationAdapter<MutableList<Any>>() {
init {
items = mutableListOf()
delegatesManager.addDelegate(MembersAdapterDelegate(clickListener))
delegatesManager.addDelegate(ProgressAdapterDelegate())
}
fun setData(events: List<Member>) {
val oldData = items.toList()
val progress = isProgress()
items.clear()
items.addAll(events)
if (progress) items.add(ProgressItem())
//yes, on main thread...
DiffUtil
.calculateDiff(DiffCallback(items, oldData), false)
.dispatchUpdatesTo(this)
}
fun showProgress(isVisible: Boolean) {
val oldData = items.toList()
val currentProgress = isProgress()
if (isVisible && !currentProgress) {
items.add(ProgressItem())
notifyItemInserted(items.lastIndex)
} else if (!isVisible && currentProgress) {
items.remove(items.last())
notifyItemRemoved(oldData.lastIndex)
}
}
private fun isProgress() = items.isNotEmpty() && items.last() is ProgressItem
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any?>
) {
super.onBindViewHolder(holder, position, payloads)
if (position == items.size - 10) nextPageListener()
}
private inner class DiffCallback(
private val newItems: List<Any>,
private val oldItems: List<Any>
) : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
return if (newItem is Member && oldItem is Member) {
newItem.id == oldItem.id && newItem.accessLevel == oldItem.accessLevel && newItem.username == oldItem.username
} else {
newItem is ProgressItem && oldItem is ProgressItem
}
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
return if (newItem is Member && oldItem is Member) {
newItem == oldItem
} else {
true
}
}
}
}
\ No newline at end of file
package ru.terrakok.gitlabclient.ui.project.members
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import com.arellomobile.mvp.presenter.InjectPresenter
import com.arellomobile.mvp.presenter.ProvidePresenter
import kotlinx.android.synthetic.main.layout_base_list.*
import ru.terrakok.gitlabclient.R
import ru.terrakok.gitlabclient.entity.Member
import ru.terrakok.gitlabclient.extension.showSnackMessage
import ru.terrakok.gitlabclient.extension.visible
import ru.terrakok.gitlabclient.presentation.project.members.ProjectMembersPresenter
import ru.terrakok.gitlabclient.presentation.project.members.ProjectMembersView
import ru.terrakok.gitlabclient.ui.global.BaseFragment
/**
* @author Valentin Logvinovitch (glvvl) on 28.02.19.
*/
class ProjectMembersFragment : BaseFragment(), ProjectMembersView {
override val layoutRes = R.layout.fragment_project_members
@InjectPresenter
lateinit var presenter: ProjectMembersPresenter
@ProvidePresenter
fun providePresenter(): ProjectMembersPresenter =
scope.getInstance(ProjectMembersPresenter::class.java)
private val adapter by lazy {
MembersAdapter(
{ presenter.onMemberClick(it) },
{ presenter.loadNextMembersPage() }
)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
recyclerView.apply {
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
adapter = this@ProjectMembersFragment.adapter
}
swipeToRefresh.setOnRefreshListener { presenter.refreshMembers() }
emptyView.setRefreshListener { presenter.refreshMembers() }
}
override fun showRefreshProgress(show: Boolean) {
postViewAction { swipeToRefresh.isRefreshing = show }
}
override fun showEmptyProgress(show: Boolean) {
fullscreenProgressView.visible(show)
//trick for disable and hide swipeToRefresh on fullscreen progress
swipeToRefresh.visible(!show)
postViewAction { swipeToRefresh.isRefreshing = false }
}
override fun showPageProgress(show: Boolean) {
postViewAction { adapter.showProgress(show) }
}
override fun showEmptyView(show: Boolean) {
emptyView.apply { if (show) showEmptyData() else hide() }
}
override fun showEmptyError(show: Boolean, message: String?) {
emptyView.apply { if (show) showEmptyError(message) else hide() }
}
override fun showMembers(show: Boolean, members: List<Member>) {
recyclerView.visible(show)
postViewAction { adapter.setData(members) }
}