Commit 80faade1 authored by Konstantin Tskhovrebov's avatar Konstantin Tskhovrebov 🤖

Merge branch 'feature/project_files_tab' into 'develop'

Implement project files

See merge request !121
parents 8579d0f8 db67ff5e
......@@ -29,6 +29,7 @@ import ru.terrakok.gitlabclient.ui.my.todos.MyTodosFragment
import ru.terrakok.gitlabclient.ui.privacypolicy.PrivacyPolicyFragment
import ru.terrakok.gitlabclient.ui.project.ProjectFlowFragment
import ru.terrakok.gitlabclient.ui.project.ProjectFragment
import ru.terrakok.gitlabclient.ui.project.files.ProjectFilesFragment
import ru.terrakok.gitlabclient.ui.project.info.ProjectEventsFragment
import ru.terrakok.gitlabclient.ui.project.info.ProjectInfoContainerFragment
import ru.terrakok.gitlabclient.ui.project.info.ProjectInfoFragment
......@@ -160,7 +161,7 @@ object Screens {
override fun getFragment() = ProjectMergeRequestsFragment.create(mrState)
}
object ProjectLabels: SupportAppScreen() {
object ProjectLabels : SupportAppScreen() {
override fun getFragment() = ProjectLabelsFragment()
}
......@@ -174,6 +175,10 @@ object Screens {
override fun getFragment() = ProjectMilestonesFragment.create(milestoneState)
}
object ProjectFiles : SupportAppScreen() {
override fun getFragment() = ProjectFilesFragment()
}
data class UserFlow(
val userId: Long
) : SupportAppScreen() {
......
package ru.terrakok.gitlabclient.entity
import com.google.gson.annotations.SerializedName
data class Branch(
@SerializedName("name") val name: String,
@SerializedName("merged") val merged: Boolean,
@SerializedName("protected") val protected: Boolean,
@SerializedName("default") val default: Boolean,
@SerializedName("developers_can_push") val developersCanPush: Boolean,
@SerializedName("developers_can_merge") val developersCanMerge: Boolean,
@SerializedName("can_push") val canPush: Boolean
)
\ No newline at end of file
package ru.terrakok.gitlabclient.entity.app
import ru.terrakok.gitlabclient.entity.RepositoryTreeNodeType
/**
* Created by Eugene Shapovalov (@CraggyHaggy) on 02.11.18.
*/
data class ProjectFile(
val id: String,
val name: String,
val nodeType: RepositoryTreeNodeType
)
\ No newline at end of file
......@@ -65,9 +65,34 @@ interface GitlabApi {
@Path("id") id: Long,
@Query("path") path: String?,
@Query("ref") branchName: String?,
@Query("recursive") recursive: Boolean?
@Query("recursive") recursive: Boolean? = false,
@Query("page") page: Int,
@Query("per_page") pageSize: Int
): Single<List<RepositoryTreeNode>>
@GET("$API_PATH/projects/{project_id}/repository/commits/{sha}")
fun getRepositoryCommit(
@Path("project_id") projectId: Long,
@Path("sha") sha: String,
@Query("stats") stats: Boolean = true
): Single<Commit>
@GET("$API_PATH/projects/{project_id}/repository/commits/")
fun getRepositoryCommits(
@Path("project_id") projectId: Long,
@Query("ref_name") branchName: String?,
@Query("since") since: String?,
@Query("until") until: String?,
@Query("path") path: String?,
@Query("all") all: Boolean?,
@Query("with_stats") withStats: Boolean?
): Single<List<Commit>>
@GET("$API_PATH/projects/{project_id}/repository/branches/")
fun getRepositoryBranches(
@Path("project_id") projectId: Long
): Single<List<Branch>>
@GET("$API_PATH/user")
fun getMyUser(): Single<User>
......
......@@ -50,7 +50,7 @@ class ProjectInteractor @Inject constructor(
val readmePath = project.readmeUrl.substringAfter(
"${project.webUrl}/blob/${project.defaultBranch}/"
)
projectRepository.getFile(project.id, readmePath, project.defaultBranch)
projectRepository.getBlobFile(project.id, readmePath, project.defaultBranch)
} else {
Single.error(ReadmeNotFound())
}
......@@ -59,6 +59,16 @@ class ProjectInteractor @Inject constructor(
.map { file -> base64Tools.decode(file.content) }
.observeOn(schedulers.ui())
fun getProjectFiles(
projectId: Long,
path: String,
branchName: String,
page: Int
) = projectRepository.getProjectFiles(projectId = projectId, path = path, branchName = branchName, page = page)
fun getProjectBranches(
projectId: Long
) = projectRepository.getProjectBranches(projectId)
class ReadmeNotFound : Exception()
}
\ No newline at end of file
package ru.terrakok.gitlabclient.model.repository.project
import io.reactivex.Single
import ru.terrakok.gitlabclient.entity.Branch
import ru.terrakok.gitlabclient.entity.OrderBy
import ru.terrakok.gitlabclient.entity.Sort
import ru.terrakok.gitlabclient.entity.Visibility
import ru.terrakok.gitlabclient.entity.app.ProjectFile
import ru.terrakok.gitlabclient.model.data.server.GitlabApi
import ru.terrakok.gitlabclient.model.system.SchedulersProvider
import ru.terrakok.gitlabclient.toothpick.PrimitiveWrapper
......@@ -53,7 +56,7 @@ class ProjectRepository @Inject constructor(
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun getFile(
fun getBlobFile(
projectId: Long,
path: String,
branchName: String
......@@ -62,13 +65,33 @@ class ProjectRepository @Inject constructor(
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun getRepositoryTree(
fun getProjectFiles(
projectId: Long,
path: String? = null,
branchName: String? = null,
recursive: Boolean? = null
) = api
.getRepositoryTree(projectId, path, branchName, recursive)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
path: String,
branchName: String,
recursive: Boolean? = null,
page: Int,
pageSize: Int = defaultPageSize
): Single<List<ProjectFile>> =
api
.getRepositoryTree(projectId, path, branchName, recursive, page, pageSize)
.map { trees ->
trees.map { tree ->
ProjectFile(
tree.id,
tree.name,
tree.type
)
}
}
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun getProjectBranches(
projectId: Long
): Single<List<Branch>> =
api
.getRepositoryBranches(projectId)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
}
\ No newline at end of file
......@@ -42,4 +42,6 @@ class ProjectPresenter @Inject constructor(
fun onLabelPressed() = flowRouter.navigateTo(Screens.ProjectLabels)
fun onMilestonesClicked() = flowRouter.navigateTo(Screens.ProjectMilestonesContainer)
fun onFilesPressed() = flowRouter.navigateTo(Screens.ProjectFiles)
}
\ No newline at end of file
package ru.terrakok.gitlabclient.presentation.project.files
import android.os.Bundle
class ProjectFileDestination {
var defaultPath: String = ""
private set
var branchName: String = ""
private set
val paths = arrayListOf<String>()
interface Callback {
fun onMoveForward(fromRoot: Boolean)
fun onMoveBack(fromRoot: Boolean)
fun onBranchChange(branchName: String)
}
private var callback: Callback? = null
fun setCallback(callback: Callback) {
this.callback = callback
}
fun init(defaultPath: String, branchName: String) {
this.defaultPath = defaultPath
this.branchName = branchName
}
fun saveState(outState: Bundle) {
outState.putString(KEY_DEFAULT_PATH, defaultPath)
outState.putString(KEY_BRANCH_NAME, branchName)
outState.putStringArrayList(KEY_PATHS, paths)
}
fun restoreState(savedState: Bundle) {
defaultPath = savedState.getString(KEY_DEFAULT_PATH)!!
branchName = savedState.getString(KEY_BRANCH_NAME)!!
paths.addAll(savedState.getStringArrayList(KEY_PATHS)!!)
}
fun isInitiated() = defaultPath.isNotEmpty() && branchName.isNotEmpty() && paths.isNotEmpty()
fun moveToRoot() {
val fromRoot = isInitiated() && !isInRoot()
if (!isInitiated()) {
paths.add(ROOT_PATH)
}
callback?.onMoveForward(fromRoot)
}
fun moveForward(path: String) {
val fromRoot = isInRoot()
paths.add(path)
callback?.onMoveForward(fromRoot)
}
fun moveBack() {
val fromRoot = isInRoot()
if (!fromRoot) {
paths.removeAt(paths.lastIndex)
}
callback?.onMoveBack(fromRoot)
}
fun isInRoot() = paths.size <= 1
fun changeBranch(branchName: String) {
if (this.branchName != branchName) {
this.branchName = branchName
this.paths.clear()
this.paths.add(ROOT_PATH)
callback?.onBranchChange(branchName)
}
}
companion object {
private const val ROOT_PATH = ""
private const val KEY_BRANCH_NAME = "key branch name"
private const val KEY_PATHS = "key paths"
private const val KEY_DEFAULT_PATH = "key default path"
}
}
\ No newline at end of file
package ru.terrakok.gitlabclient.presentation.project.files
import com.arellomobile.mvp.InjectViewState
import io.reactivex.Single
import io.reactivex.functions.BiFunction
import ru.terrakok.gitlabclient.R
import ru.terrakok.gitlabclient.entity.Branch
import ru.terrakok.gitlabclient.entity.Project
import ru.terrakok.gitlabclient.entity.RepositoryTreeNodeType
import ru.terrakok.gitlabclient.entity.app.ProjectFile
import ru.terrakok.gitlabclient.model.interactor.project.ProjectInteractor
import ru.terrakok.gitlabclient.model.system.ResourceManager
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 ru.terrakok.gitlabclient.toothpick.PrimitiveWrapper
import ru.terrakok.gitlabclient.toothpick.qualifier.ProjectId
import javax.inject.Inject
/**
* Created by Eugene Shapovalov (@CraggyHaggy) on 02.11.18.
*/
@InjectViewState
class ProjectFilesPresenter @Inject constructor(
@ProjectId private val projectIdWrapper: PrimitiveWrapper<Long>,
private val projectInteractor: ProjectInteractor,
private val errorHandler: ErrorHandler,
private val router: FlowRouter,
private val projectFileDestination: ProjectFileDestination,
private val resourceManager: ResourceManager
) : BasePresenter<ProjectFilesView>() {
private val projectId = projectIdWrapper.value
private val projectBranches = arrayListOf<Branch>()
init {
projectFileDestination.setCallback(object : ProjectFileDestination.Callback {
override fun onMoveForward(fromRoot: Boolean) {
val inRoot = projectFileDestination.isInRoot()
if (fromRoot) {
viewState.setBranch("")
viewState.showBranchSelection(false)
} else if (inRoot) {
viewState.setBranch(projectFileDestination.branchName)
viewState.showBranchSelection(true)
}
viewState.setPath(getUIPath(inRoot, projectFileDestination.defaultPath, projectFileDestination.paths))
refreshFiles()
}
override fun onMoveBack(fromRoot: Boolean) {
if (fromRoot) {
router.exit()
} else {
val inRoot = projectFileDestination.isInRoot()
if (inRoot) {
viewState.setBranch(projectFileDestination.branchName)
viewState.showBranchSelection(true)
}
viewState.setPath(
getUIPath(inRoot, projectFileDestination.defaultPath, projectFileDestination.paths)
)
refreshFiles()
}
}
override fun onBranchChange(branchName: String) {
viewState.setBranch(branchName)
refreshFiles()
}
})
}
override fun onFirstViewAttach() {
super.onFirstViewAttach()
if (projectFileDestination.isInitiated()) {
loadBranches()
} else {
loadProjectWithBranches()
}
}
private fun loadBranches() {
projectInteractor.getProjectBranches(projectId)
.doOnSubscribe { viewState.showBlockingProgress(true) }
.doAfterTerminate { viewState.showBlockingProgress(false) }
.subscribe(
{
viewState.showBranchSelection(true)
projectBranches.addAll(it)
projectFileDestination.moveToRoot()
},
{ handleLoadingProjectDetailsError(it) }
)
.connect()
}
private fun loadProjectWithBranches() {
Single
.zip(
projectInteractor.getProject(projectId),
projectInteractor.getProjectBranches(projectId),
BiFunction<Project, List<Branch>, Pair<Project, List<Branch>>> { project, branches ->
Pair(project, branches)
}
)
.doOnSubscribe { viewState.showBlockingProgress(true) }
.doAfterTerminate { viewState.showBlockingProgress(false) }
.subscribe(
{ (project, branches) ->
viewState.showBranchSelection(true)
projectBranches.addAll(branches)
projectFileDestination.init(project.path, project.defaultBranch)
projectFileDestination.moveToRoot()
},
{ handleLoadingProjectDetailsError(it) }
)
.connect()
}
private fun handleLoadingProjectDetailsError(error: Throwable) {
viewState.setPath(resourceManager.getString(R.string.project_files_default_path))
viewState.showBranchSelection(false)
errorHandler.proceed(error, { viewState.showEmptyError(true, it) })
}
private val paginator = Paginator(
{
projectInteractor.getProjectFiles(
projectId,
getRemotePath(projectFileDestination.isInRoot(), projectFileDestination.paths),
projectFileDestination.branchName,
it
)
},
object : Paginator.ViewController<ProjectFile> {
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) {
// Hide old files to prevent navigation into the same directories.
viewState.showEmptyView(true)
viewState.showFiles(false, emptyList())
errorHandler.proceed(error, { viewState.showEmptyError(true, it) })
}
override fun showEmptyView(show: Boolean) {
viewState.showEmptyView(show)
}
override fun showData(show: Boolean, data: List<ProjectFile>) {
// Hide empty view, if it was hidden in case of error.
viewState.showEmptyView(false)
viewState.showFiles(show, data)
}
override fun showRefreshProgress(show: Boolean) {
viewState.showRefreshProgress(show)
}
override fun showPageProgress(show: Boolean) {
viewState.showPageProgress(show)
}
}
)
fun refreshFiles() {
if (projectFileDestination.isInitiated()) {
paginator.refresh()
} else {
viewState.showEmptyError(false, null)
viewState.showRefreshProgress(false)
loadProjectWithBranches()
}
}
fun loadNextFilesPage() = paginator.loadNewPage()
fun onShowBranchesClick() = viewState.showBranches(projectBranches)
fun onBackPressed() = projectFileDestination.moveBack()
fun onBranchClick(branchName: String) = projectFileDestination.changeBranch(branchName)
fun onFileClick(item: ProjectFile) {
if (item.nodeType == RepositoryTreeNodeType.TREE) {
projectFileDestination.moveForward(item.name)
} else {
// TODO: file details (Navigate to file details flow).
}
}
override fun onDestroy() {
super.onDestroy()
paginator.release()
}
companion object {
private const val UI_SEPARATOR = " / "
private const val REMOTE_SEPARATOR = "/"
private fun getUIPath(inRoot: Boolean, defaultPath: String, paths: List<String>) =
if (inRoot) {
defaultPath
} else {
"$defaultPath$UI_SEPARATOR${paths.subList(1, paths.size).joinToString(separator = UI_SEPARATOR)}"
}
private fun getRemotePath(inRoot: Boolean, paths: List<String>) =
if (inRoot) {
""
} else {
paths.subList(1, paths.size).joinToString(separator = REMOTE_SEPARATOR)
}
}
}
\ No newline at end of file
package ru.terrakok.gitlabclient.presentation.project.files
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.Branch
import ru.terrakok.gitlabclient.entity.app.ProjectFile
/**
* @author Eugene Shapovalov (CraggyHaggy). Date: 02.11.18
*/
@StateStrategyType(AddToEndSingleStrategy::class)
interface ProjectFilesView : MvpView {
fun setPath(path: String)
fun setBranch(branchName: String)
fun showRefreshProgress(show: Boolean)
fun showEmptyProgress(show: Boolean)
fun showPageProgress(show: Boolean)
fun showEmptyView(show: Boolean)
fun showEmptyError(show: Boolean, message: String?)
fun showFiles(show: Boolean, files: List<ProjectFile>)
fun showBlockingProgress(show: Boolean)
fun showBranchSelection(show: Boolean)
@StateStrategyType(OneExecutionStateStrategy::class)
fun showBranches(branches: List<Branch>)
@StateStrategyType(OneExecutionStateStrategy::class)
fun showMessage(message: String)
}
\ No newline at end of file
package ru.terrakok.gitlabclient.ui.global.list
import android.support.v7.widget.RecyclerView
import android.view.View
import android.view.ViewGroup
import com.hannesdorfmann.adapterdelegates3.AdapterDelegate
import kotlinx.android.synthetic.main.item_project_file.view.*
import ru.terrakok.gitlabclient.R
import ru.terrakok.gitlabclient.entity.RepositoryTreeNodeType
import ru.terrakok.gitlabclient.entity.app.ProjectFile
import ru.terrakok.gitlabclient.extension.inflate
/**
* Created by Eugene Shapovalov (@CraggyHaggy) on 02.11.18.
*/
class ProjectFileAdapterDelegate(
private val fileClickListener: (ProjectFile) -> Unit
) : AdapterDelegate<MutableList<Any>>() {
override fun isForViewType(items: MutableList<Any>, position: Int) =
items[position] is ProjectFile
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder =
ViewHolder(parent.inflate(R.layout.item_project_file))
override fun onBindViewHolder(
items: MutableList<Any>,
position: Int,
viewHolder: RecyclerView.ViewHolder,
payloads: MutableList<Any>
) = (viewHolder as ViewHolder).bind(items[position] as ProjectFile)
private inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private lateinit var item: ProjectFile
init {
view.setOnClickListener { fileClickListener(item) }
}
fun bind(item: ProjectFile) {
this.item = item
with(itemView) {
projectFileIcon.setImageResource(
when (item.nodeType) {
RepositoryTreeNodeType.BLOB -> R.drawable.ic_file
RepositoryTreeNodeType.TREE -> R.drawable.ic_folder
}
)
projectFileName.text = item.name
}
}
}
}
\ No newline at end of file
......@@ -80,7 +80,7 @@ class TargetCommitsAdapter(
return if (newItem is CommitWithAvatarUrl && oldItem is CommitWithAvatarUrl) {
newItem == oldItem
} else {
true
false
}
}
}
......
......@@ -90,7 +90,7 @@ class TargetsAdapter(
return if (newItem is TargetHeader && oldItem is TargetHeader) {
newItem == oldItem
} else {
true
false
}
}
}
......
......@@ -45,6 +45,7 @@ class ProjectFragment : BaseFragment(), ProjectView {
R.id.shareAction -> shareText(shareUrl)
R.id.labelAction -> presenter.onLabelPressed()
R.id.milestonesAction -> presenter.onMilestonesClicked()
R.id.filesAction -> presenter.onFilesPressed()
}
true
}
......
package ru.terrakok.gitlabclient.ui.project.files
import android.support.v7.util.DiffUtil
import android.support.v7.widget.RecyclerView
import com.hannesdorfmann.adapterdelegates3.ListDelegationAdapter
import ru.terrakok.gitlabclient.entity.app.ProjectFile
import ru.terrakok.gitlabclient.ui.global.list.ProgressAdapterDelegate
import ru.terrakok.gitlabclient.ui.global.list.ProgressItem
import ru.terrakok.gitlabclient.ui.global.list.ProjectFileAdapterDelegate
/**
* Created by Eugene Shapovalov (@CraggyHaggy) on 02.11.18.
*/
class ProjectFilesAdapter(
fileClickListener: (ProjectFile) -> Unit,
private val nextPageListener: () -> Unit
) : ListDelegationAdapter<MutableList<Any>>() {
init {
items = mutableListOf()
delegatesManager.addDelegate(ProjectFileAdapterDelegate(fileClickListener))
delegatesManager.addDelegate(ProgressAdapterDelegate())
}
fun setData(data: List<ProjectFile>) {
val oldItems = items.toList()
items.clear()
items.addAll(data)
DiffUtil
.calculateDiff(DiffCallback(items, oldItems), false)
.dispatchUpdatesTo(this)
}