Skip to content

Commit

Permalink
NT-1968:UX – Add comment composer to thread (#1291)
Browse files Browse the repository at this point in the history
* HAndle Add comment composer to thread Activity

* Add test

* Add start thread activity to viewModel to pass project to thread activity

* Add test case

* Show key board in case pressing replay

* Rename

* Rename to reply

* Rename to reply

Co-authored-by: Isabel Martin <arkariang@gmail.com>
  • Loading branch information
hadia and Arkariang committed Jun 17, 2021
1 parent 4bb43e3 commit 1bf8878
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 7 deletions.
Expand Up @@ -13,6 +13,7 @@ import com.kickstarter.libs.utils.ApplicationUtils
import com.kickstarter.libs.utils.UrlUtils
import com.kickstarter.libs.utils.extensions.toVisibility
import com.kickstarter.models.Comment
import com.kickstarter.models.Project
import com.kickstarter.ui.IntentKey
import com.kickstarter.ui.adapters.CommentsAdapter
import com.kickstarter.ui.extensions.hideKeyboard
Expand Down Expand Up @@ -165,6 +166,13 @@ class CommentsActivity :
.subscribe {
paginationHandler.refreshing(it)
}

viewModel.outputs.startThreadActivity()
.compose(bindToLifecycle())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
startThreadActivity(it.first.first, it.second, it.first.second)
}
}

fun postComment(comment: String) {
Expand Down Expand Up @@ -197,7 +205,7 @@ class CommentsActivity :
}

override fun onReplyButtonClicked(comment: Comment) {
startThreadActivity(comment, true)
viewModel.inputs.onReplyClicked(comment, true)
}

override fun onFlagButtonClicked(comment: Comment) {
Expand All @@ -212,7 +220,7 @@ class CommentsActivity :
}

override fun onCommentRepliesClicked(comment: Comment) {
startThreadActivity(comment, false)
viewModel.inputs.onReplyClicked(comment, false)
}

/**
Expand All @@ -225,9 +233,10 @@ class CommentsActivity :
* // TODO: Once the viewReplies UI is completed call this method with openKeyboard = false
* // TODO: https://kickstarter.atlassian.net/browse/NT-1955
*/
private fun startThreadActivity(comment: Comment, openKeyboard: Boolean) {
private fun startThreadActivity(comment: Comment, project: Project, openKeyboard: Boolean) {
val threadIntent = Intent(this, ThreadActivity::class.java).apply {
putExtra(IntentKey.COMMENT, comment)
putExtra(IntentKey.PROJECT, project)
putExtra(IntentKey.REPLY_EXPAND, openKeyboard)
}

Expand Down
@@ -1,15 +1,18 @@
package com.kickstarter.ui.activities

import android.os.Bundle
import androidx.core.view.isVisible
import com.kickstarter.databinding.ActivityThreadLayoutBinding
import com.kickstarter.libs.BaseActivity
import com.kickstarter.libs.KSString
import com.kickstarter.libs.qualifiers.RequiresActivityViewModel
import com.kickstarter.libs.utils.DateTimeUtils
import com.kickstarter.models.Comment
import com.kickstarter.ui.extensions.hideKeyboard
import com.kickstarter.ui.views.OnCommentComposerViewClickedListener
import com.kickstarter.viewmodels.ThreadViewModel
import rx.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit

@RequiresActivityViewModel(ThreadViewModel.ViewModel::class)
class ThreadActivity : BaseActivity<ThreadViewModel.ViewModel>() {
Expand All @@ -32,11 +35,41 @@ class ThreadActivity : BaseActivity<ThreadViewModel.ViewModel>() {
}

this.viewModel.shouldFocusOnCompose()
.delay(30, TimeUnit.MILLISECONDS)
.compose(bindToLifecycle())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { shouldOpenKeyboard ->
// TODO: Once compose view is integrated we can set focus and open the keyboard
binding.replyComposer.requestCommentComposerKeyBoard(shouldOpenKeyboard)
}

viewModel.outputs.currentUserAvatar()
.compose(bindToLifecycle())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
binding.replyComposer.setAvatarUrl(it)
}

viewModel.outputs.replyComposerStatus()
.compose(bindToLifecycle())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
binding.replyComposer.setCommentComposerStatus(it)
}

viewModel.outputs.showReplyComposer()
.compose(bindToLifecycle())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
binding.replyComposer.isVisible = it
}

binding.replyComposer.setCommentComposerActionClickListener(object :
OnCommentComposerViewClickedListener {
override fun onClickActionListener(string: String) {
// TODO add Post Replay
hideKeyboard()
}
})
}

override fun onStop() {
Expand Down
Expand Up @@ -2,6 +2,7 @@ package com.kickstarter.ui.views
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.inputmethod.InputMethodManager
import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.withStyledAttributes
Expand Down Expand Up @@ -84,6 +85,14 @@ class CommentComposerView @JvmOverloads constructor(
binding.commentTextComposer.hint = context.getString(hint)
}

fun requestCommentComposerKeyBoard(isFocusable: Boolean) {
if (isFocusable) {
binding.commentTextComposer.requestFocus()
val inputManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputManager.showSoftInput(binding.commentTextComposer, 0)
}
}

fun setCommentComposerStatus(commentComposerStatus: CommentComposerStatus) {
when (commentComposerStatus) {
CommentComposerStatus.ENABLED -> showCommentComposerEnabledView()
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/kickstarter/viewmodels/CommentsViewModel.kt
Expand Up @@ -36,6 +36,7 @@ interface CommentsViewModel {
fun nextPage()
fun backPressed()
fun insertNewCommentToList(comment: String, createdAt: DateTime)
fun onReplyClicked(comment: Comment, openKeyboard: Boolean)
fun onShowGuideLinesLinkClicked()

/** Will be called with the successful response when calling the `postComment` Mutation **/
Expand All @@ -55,6 +56,7 @@ interface CommentsViewModel {
fun initialLoadCommentsError(): Observable<Throwable>
fun paginateCommentsError(): Observable<Throwable>
fun pullToRefreshError(): Observable<Throwable>
fun startThreadActivity(): Observable<Pair<Pair<Comment, Boolean>, Project>>

/** Display the bottom pagination Error Cell **/
fun shouldShowPaginationErrorUI(): Observable<Boolean>
Expand All @@ -71,6 +73,7 @@ interface CommentsViewModel {
private val refresh = PublishSubject.create<Void>()
private val nextPage = PublishSubject.create<Void>()
private val onShowGuideLinesLinkClicked = PublishSubject.create<Void>()
private val onReplayClicked = PublishSubject.create<Pair<Comment, Boolean>>()

private val closeCommentsPage = BehaviorSubject.create<Void>()
private val currentUserAvatar = BehaviorSubject.create<String?>()
Expand All @@ -89,6 +92,7 @@ interface CommentsViewModel {
private val setEmptyState = BehaviorSubject.create<Boolean>()
private val displayPaginationError = BehaviorSubject.create<Boolean>()
private val commentToRefresh = PublishSubject.create<Comment>()
private val startThreadActivity = BehaviorSubject.create<Pair<Pair<Comment, Boolean>, Project>>()

// - Error observables to handle the 3 different use cases
private val internalError = BehaviorSubject.create<Throwable>()
Expand Down Expand Up @@ -241,6 +245,13 @@ interface CommentsViewModel {
.compose(bindToLifecycle())
.subscribe { this.closeCommentsPage.onNext(it) }

this.onReplayClicked
.compose(combineLatestPair(initialProject))
.compose(bindToLifecycle())
.subscribe {
this.startThreadActivity.onNext(it)
}

// - Update internal mutable list with the latest state after successful response
this.commentToRefresh
.map { updateCommentAfterSuccessfulPost(it) }
Expand Down Expand Up @@ -381,6 +392,7 @@ interface CommentsViewModel {
override fun insertNewCommentToList(comment: String, createdAt: DateTime) = insertNewCommentToList.onNext(Pair(comment, createdAt))
override fun onShowGuideLinesLinkClicked() = onShowGuideLinesLinkClicked.onNext(null)
override fun refreshComment(comment: Comment) = this.commentToRefresh.onNext(comment)
override fun onReplyClicked(comment: Comment, openKeyboard: Boolean) = onReplayClicked.onNext(Pair(comment, openKeyboard))

// - Outputs
override fun closeCommentsPage(): Observable<Void> = closeCommentsPage
Expand All @@ -401,6 +413,8 @@ interface CommentsViewModel {
override fun enablePagination(): Observable<Boolean> = enablePagination
override fun isRefreshing(): Observable<Boolean> = isRefreshing

override fun startThreadActivity(): Observable<Pair<Pair<Comment, Boolean>, Project>> = this.startThreadActivity

override fun bindPaginatedData(data: List<CommentCardData>?) {
lastCommentCursor = data?.lastOrNull()?.comment?.cursor()
val newList = data?.let { it } ?: emptyList()
Expand Down
53 changes: 53 additions & 0 deletions app/src/main/java/com/kickstarter/viewmodels/ThreadViewModel.kt
@@ -1,14 +1,20 @@
package com.kickstarter.viewmodels

import android.util.Pair
import androidx.annotation.NonNull
import com.kickstarter.libs.ActivityViewModel
import com.kickstarter.libs.CurrentUserType
import com.kickstarter.libs.Environment
import com.kickstarter.libs.rx.transformers.Transformers
import com.kickstarter.libs.utils.ObjectUtils
import com.kickstarter.libs.utils.ProjectUtils
import com.kickstarter.models.Comment
import com.kickstarter.models.Project
import com.kickstarter.models.User
import com.kickstarter.services.ApolloClientType
import com.kickstarter.ui.IntentKey
import com.kickstarter.ui.activities.ThreadActivity
import com.kickstarter.ui.views.CommentComposerStatus
import rx.Observable
import rx.subjects.BehaviorSubject
import timber.log.Timber
Expand All @@ -22,6 +28,10 @@ interface ThreadViewModel {

/** Will tell to the compose view if should open the keyboard */
fun shouldFocusOnCompose(): Observable<Boolean>

fun currentUserAvatar(): Observable<String?>
fun replyComposerStatus(): Observable<CommentComposerStatus>
fun showReplyComposer(): Observable<Boolean>
}

class ViewModel(@NonNull val environment: Environment) : ActivityViewModel<ThreadActivity>(environment), Inputs, Outputs {
Expand All @@ -30,6 +40,9 @@ interface ThreadViewModel {

private val rootComment = BehaviorSubject.create<Comment>()
private val focusOnCompose = BehaviorSubject.create<Boolean>()
private val currentUserAvatar = BehaviorSubject.create<String?>()
private val replyComposerStatus = BehaviorSubject.create<CommentComposerStatus>()
private val showReplyComposer = BehaviorSubject.create<Boolean>()

val inputs = this
val outputs = this
Expand Down Expand Up @@ -69,13 +82,53 @@ interface ThreadViewModel {
comment
.compose(bindToLifecycle())
.subscribe(this.rootComment)

val project = intent()
.map { it.getParcelableExtra(IntentKey.PROJECT) as Project? }
.filter { ObjectUtils.isNotNull(it) }
.map { requireNotNull(it) }

val loggedInUser = this.currentUser.loggedInUser()
.filter { u -> u != null }
.map { requireNotNull(it) }

loggedInUser
.compose(bindToLifecycle())
.subscribe {
currentUserAvatar.onNext(it.avatar().small())
}

loggedInUser
.compose(bindToLifecycle())
.subscribe {
showReplyComposer.onNext(true)
}

project
.compose(Transformers.combineLatestPair(currentUser.observable()))
.compose(bindToLifecycle())
.subscribe {
val composerStatus = getCommentComposerStatus(Pair(it.first, it.second))
showReplyComposer.onNext(composerStatus != CommentComposerStatus.GONE)
replyComposerStatus.onNext(composerStatus)
}
}

private fun getCommentComposerStatus(projectAndUser: Pair<Project, User?>) =
when {
projectAndUser.second == null -> CommentComposerStatus.GONE
projectAndUser.first.isBacking || ProjectUtils.userIsCreator(projectAndUser.first, projectAndUser.second) -> CommentComposerStatus.ENABLED
else -> CommentComposerStatus.DISABLED
}

private fun getCommentFromIntent() = intent()
.map { it.getParcelableExtra(IntentKey.COMMENT) as Comment? }
.ofType(Comment::class.java)

override fun getRootComment(): Observable<Comment> = this.rootComment
override fun shouldFocusOnCompose(): Observable<Boolean> = this.focusOnCompose
override fun currentUserAvatar(): Observable<String?> = currentUserAvatar
override fun replyComposerStatus(): Observable<CommentComposerStatus> = replyComposerStatus
override fun showReplyComposer(): Observable<Boolean> = showReplyComposer
}
}
17 changes: 14 additions & 3 deletions app/src/main/res/layout/activity_thread_layout.xml
Expand Up @@ -37,7 +37,7 @@

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:focusable="true"
tools:context="com.kickstarter.ui.activities.ThreadActivity"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
Expand All @@ -62,14 +62,25 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/comment_replies_recycler_view"
app:layout_constraintStart_toEndOf="@id/left_guideline"
app:layout_constraintEnd_toEndOf="parent"
android:layout_width="0dp"
android:paddingEnd="@dimen/grid_5"
android:layout_height="wrap_content"
android:layout_height="0dp"
android:background="@color/kds_white"
app:layout_constraintBottom_toTopOf="@+id/reply_composer"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintTop_toBottomOf="@id/comments_card_view"
tools:layout_editor_absoluteX="0dp"
tools:listitem="@layout/comment_card" />

<com.kickstarter.ui.views.CommentComposerView
android:id="@+id/reply_composer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:composer_action_title ="@string/general_navigation_buttons_reply"
app:composer_disabled="false"
app:composer_action_button_gone="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -1,6 +1,7 @@
package com.kickstarter.viewmodels

import android.content.Intent
import android.util.Pair
import com.kickstarter.KSRobolectricTestCase
import com.kickstarter.libs.MockCurrentUser
import com.kickstarter.mock.factories.ApiExceptionFactory
Expand All @@ -11,6 +12,8 @@ import com.kickstarter.mock.factories.ProjectFactory
import com.kickstarter.mock.factories.UpdateFactory
import com.kickstarter.mock.factories.UserFactory
import com.kickstarter.mock.services.MockApolloClient
import com.kickstarter.models.Comment
import com.kickstarter.models.Project
import com.kickstarter.services.apiresponses.commentresponse.CommentEnvelope
import com.kickstarter.ui.IntentKey
import com.kickstarter.ui.data.CommentCardData
Expand Down Expand Up @@ -39,6 +42,7 @@ class CommentsViewModelTest : KSRobolectricTestCase() {
private val paginationError = TestSubscriber.create<Throwable>()
private val shouldShowPaginatedCell = TestSubscriber.create<Boolean>()
private val openCommentGuideLines = TestSubscriber<Void>()
private val startThreadActivity = BehaviorSubject.create<Pair<Pair<Comment, Boolean>, Project>>()

@Test
fun testCommentsViewModel_whenUserLoggedInAndBacking_shouldShowEnabledComposer() {
Expand Down Expand Up @@ -407,6 +411,33 @@ class CommentsViewModelTest : KSRobolectricTestCase() {
closeCommentPage.assertValueCount(1)
}

@Test
fun onReplyClicked_whenEmits_shouldEmitToCloseThreadActivity() {
val currentUser = UserFactory.user()
.toBuilder()
.id(1)
.avatar(AvatarFactory.avatar())
.build()

val createdAt = DateTime.now()

val vm = CommentsViewModel.ViewModel(
environment().toBuilder().currentUser(MockCurrentUser(currentUser)).build()
)
val comment = CommentFactory.liveComment(createdAt = createdAt)
val project = ProjectFactory.project()
vm.outputs.startThreadActivity().subscribe(startThreadActivity)
vm.intent(Intent().putExtra(IntentKey.PROJECT, project))

// Start the view model with a project.

vm.inputs.onReplyClicked(comment, true)

assertEquals(startThreadActivity.value.second, project)
assertEquals(startThreadActivity.value.first.first, comment)
assertTrue(startThreadActivity.value.first.second)
}

@Test
fun testComments_UpdateCommentStateAfterPost() {
val currentUser = UserFactory.user()
Expand Down

0 comments on commit 1bf8878

Please sign in to comment.