This commit is contained in:
Erik Cavazos 2023-09-02 23:33:32 -06:00
parent 435d669399
commit 3c146ee603
14 changed files with 350 additions and 25 deletions

View File

@ -1,5 +1,6 @@
package com.isolaatti.posting.comments.data.remote package com.isolaatti.posting.comments.data.remote
import com.isolaatti.utils.LongIdentificationWrapper
import retrofit2.Call import retrofit2.Call
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@ -16,4 +17,10 @@ interface CommentsApi {
@GET("Fetch/Comments/{commentId}") @GET("Fetch/Comments/{commentId}")
fun getComment(@Path("commentId") commentId: Long): Call<CommentDto> fun getComment(@Path("commentId") commentId: Long): Call<CommentDto>
@POST("Comment/{commentId}/Edit")
fun editComment(@Path("commentId") commentId: Long, @Body commentDto: CommentToPostDto): Call<CommentDto>
@POST("Comment/Delete")
fun deleteComment(@Body commentId: LongIdentificationWrapper): Call<Void>
} }

View File

@ -5,6 +5,7 @@ import com.isolaatti.posting.comments.data.remote.CommentToPostDto
import com.isolaatti.posting.comments.data.remote.CommentsApi import com.isolaatti.posting.comments.data.remote.CommentsApi
import com.isolaatti.posting.comments.domain.CommentsRepository import com.isolaatti.posting.comments.domain.CommentsRepository
import com.isolaatti.posting.comments.domain.model.Comment import com.isolaatti.posting.comments.domain.model.Comment
import com.isolaatti.utils.LongIdentificationWrapper
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -40,4 +41,31 @@ class CommentsRepositoryImpl @Inject constructor(private val commentsApi: Commen
} }
emit(Resource.Error()) emit(Resource.Error())
} }
override fun editComment(commentId: Long, content: String, audioId: String?): Flow<Resource<Comment>> = flow {
emit(Resource.Loading())
val commentToPostDto = CommentToPostDto(content, audioId)
val response = commentsApi.editComment(commentId, commentToPostDto).awaitResponse()
if(response.isSuccessful) {
val responseBody = response.body()
if(responseBody != null) {
emit(Resource.Success(Comment.fromCommentDto(responseBody)))
return@flow
}
}
emit(Resource.Error())
}
override fun deleteComment(commentId: Long): Flow<Resource<Boolean>> = flow {
emit(Resource.Loading())
val response = commentsApi.deleteComment(LongIdentificationWrapper(commentId)).awaitResponse()
if(response.isSuccessful) {
emit(Resource.Success(true))
return@flow
}
emit(Resource.Error())
}
} }

View File

@ -9,4 +9,6 @@ interface CommentsRepository {
fun getComments(postId: Long, lastId: Long): Flow<MutableList<Comment>> fun getComments(postId: Long, lastId: Long): Flow<MutableList<Comment>>
fun getComment(commentId: Long): Flow<CommentDto> fun getComment(commentId: Long): Flow<CommentDto>
fun postComment(content: String, audioId: String?, postId: Long): Flow<Resource<Comment>> fun postComment(content: String, audioId: String?, postId: Long): Flow<Resource<Comment>>
fun editComment(commentId: Long, content: String, audioId: String?): Flow<Resource<Comment>>
fun deleteComment(commentId: Long): Flow<Resource<Boolean>>
} }

View File

@ -0,0 +1,12 @@
package com.isolaatti.posting.comments.domain.use_case
import com.isolaatti.posting.comments.domain.CommentsRepository
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class DeleteComment @Inject constructor(private val commentsRepository: CommentsRepository) {
operator fun invoke(commentId: Long): Flow<Resource<Boolean>> {
return commentsRepository.deleteComment(commentId)
}
}

View File

@ -0,0 +1,13 @@
package com.isolaatti.posting.comments.domain.use_case
import com.isolaatti.posting.comments.domain.CommentsRepository
import com.isolaatti.posting.comments.domain.model.Comment
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class EditComment @Inject constructor(private val commentsRepository: CommentsRepository) {
operator fun invoke(commentId: Long, content: String, audioId: String?): Flow<Resource<Comment>> {
return commentsRepository.editComment(commentId,content,audioId)
}
}

View File

@ -73,7 +73,7 @@ class CommentsRecyclerViewAdapter(private var list: List<Comment>, private val m
return return
} }
val commentUpdated = updateEvent.affectedPosition?.let { list?.get(it) } val commentUpdated = updateEvent.affectedPosition?.let { list[it] }
val position = updateEvent.affectedPosition val position = updateEvent.affectedPosition
previousSize = itemCount previousSize = itemCount
@ -97,6 +97,12 @@ class CommentsRecyclerViewAdapter(private var list: List<Comment>, private val m
UpdateEvent.UpdateType.REFRESH -> { UpdateEvent.UpdateType.REFRESH -> {
notifyDataSetChanged() notifyDataSetChanged()
} }
UpdateEvent.UpdateType.COMMENT_UPDATED -> {
if(updateEvent.affectedPosition != null) {
notifyItemChanged(updateEvent.affectedPosition)
}
}
} }
} }
} }

View File

@ -4,8 +4,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.isolaatti.posting.comments.data.remote.CommentDto
import com.isolaatti.posting.comments.domain.model.Comment import com.isolaatti.posting.comments.domain.model.Comment
import com.isolaatti.posting.comments.domain.use_case.DeleteComment
import com.isolaatti.posting.comments.domain.use_case.EditComment
import com.isolaatti.posting.comments.domain.use_case.GetComments import com.isolaatti.posting.comments.domain.use_case.GetComments
import com.isolaatti.posting.comments.domain.use_case.PostComment import com.isolaatti.posting.comments.domain.use_case.PostComment
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
@ -18,7 +19,12 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CommentsViewModel @Inject constructor(private val getComments: GetComments, private val postComment: PostComment) : ViewModel() { class CommentsViewModel @Inject constructor(
private val getComments: GetComments,
private val postComment: PostComment,
private val editCommentUseCase: EditComment,
private val deleteCommentUseCase: DeleteComment
) : ViewModel() {
private val commentsList: MutableList<Comment> = mutableListOf() private val commentsList: MutableList<Comment> = mutableListOf()
private val _comments: MutableLiveData<Pair<List<Comment>, UpdateEvent>> = MutableLiveData() private val _comments: MutableLiveData<Pair<List<Comment>, UpdateEvent>> = MutableLiveData()
@ -26,8 +32,9 @@ class CommentsViewModel @Inject constructor(private val getComments: GetComments
val comments: LiveData<Pair<List<Comment>, UpdateEvent>> get() = _comments val comments: LiveData<Pair<List<Comment>, UpdateEvent>> get() = _comments
val commentPosted: MutableLiveData<Boolean?> = MutableLiveData() val commentPosted: MutableLiveData<Boolean?> = MutableLiveData()
val noMoreContent: MutableLiveData<Boolean?> = MutableLiveData() val noMoreContent: MutableLiveData<Boolean?> = MutableLiveData()
val commentToEdit: MutableLiveData<Comment> = MutableLiveData() val commentToEdit: MutableLiveData<Comment?> = MutableLiveData()
val finishedEditingComment: MutableLiveData<Boolean?> = MutableLiveData() val finishedEditingComment: MutableLiveData<Boolean?> = MutableLiveData()
val error: MutableLiveData<Boolean> = MutableLiveData(false)
/** /**
* postId to query comments for. First page will be fetched when set. * postId to query comments for. First page will be fetched when set.
@ -47,7 +54,8 @@ class CommentsViewModel @Inject constructor(private val getComments: GetComments
commentsList.clear() commentsList.clear()
} }
getComments(postId, lastId).onEach { getComments(postId, lastId).onEach {
val eventType = if((commentsList.isNotEmpty())) UpdateEvent.UpdateType.COMMENT_PAGE_ADDED_BOTTOM else UpdateEvent.UpdateType.REFRESH val eventType =
if ((commentsList.isNotEmpty())) UpdateEvent.UpdateType.COMMENT_PAGE_ADDED_BOTTOM else UpdateEvent.UpdateType.REFRESH
commentsList.addAll(it) commentsList.addAll(it)
_comments.postValue(Pair(commentsList, UpdateEvent(eventType, null))) _comments.postValue(Pair(commentsList, UpdateEvent(eventType, null)))
if (it.isEmpty()) { if (it.isEmpty()) {
@ -69,11 +77,64 @@ class CommentsViewModel @Inject constructor(private val getComments: GetComments
commentPosted.postValue(true) commentPosted.postValue(true)
putCommentAtTheBeginning(it.data!!) putCommentAtTheBeginning(it.data!!)
} }
is Resource.Loading -> { is Resource.Loading -> {
} }
is Resource.Error -> { is Resource.Error -> {
commentPosted.postValue(false) commentPosted.postValue(false)
error.postValue(true)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
fun editComment(newContent: String) {
// TODO handle audios
val comment = commentToEdit.value ?: return
viewModelScope.launch {
commentToEdit.postValue(null)
editCommentUseCase(comment.id, newContent, null).onEach { commentResource ->
when (commentResource) {
is Resource.Success -> {
val newComment = commentResource.data ?: return@onEach
val oldComment =
commentsList.find { it.id == newComment.id } ?: return@onEach
val index = commentsList.indexOf(oldComment)
commentsList[index] = newComment
val updateEvent = UpdateEvent(UpdateEvent.UpdateType.COMMENT_UPDATED, index)
_comments.postValue(Pair(commentsList, updateEvent))
finishedEditingComment.postValue(true)
}
is Resource.Error -> {
error.postValue(true)
}
is Resource.Loading -> {}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
fun deleteComment(commentId: Long) {
val index =
commentsList.find { it.id == commentId }?.let { commentsList.indexOf(it) } ?: return
viewModelScope.launch {
deleteCommentUseCase(commentId).onEach {
when(it) {
is Resource.Error -> {
error.postValue(true)
}
is Resource.Loading -> {}
is Resource.Success -> {
commentsList.removeAt(index)
_comments.postValue(Pair(commentsList, UpdateEvent(UpdateEvent.UpdateType.COMMENT_REMOVED, index)))
} }
} }
}.flowOn(Dispatchers.IO).launchIn(this) }.flowOn(Dispatchers.IO).launchIn(this)
@ -92,7 +153,12 @@ class CommentsViewModel @Inject constructor(private val getComments: GetComments
newList.addAll(commentsList) newList.addAll(commentsList)
commentsList.clear() commentsList.clear()
commentsList.addAll(newList) commentsList.addAll(newList)
_comments.postValue(Pair(commentsList, UpdateEvent(UpdateEvent.UpdateType.COMMENT_ADDED_TOP, null))) _comments.postValue(
Pair(
commentsList,
UpdateEvent(UpdateEvent.UpdateType.COMMENT_ADDED_TOP, null)
)
)
} }
fun switchToEditMode(comment: Comment) { fun switchToEditMode(comment: Comment) {

View File

@ -6,6 +6,7 @@ data class UpdateEvent(val updateType: UpdateType, val affectedPosition: Int?) {
COMMENT_REMOVED, COMMENT_REMOVED,
COMMENT_ADDED_TOP, COMMENT_ADDED_TOP,
COMMENT_PAGE_ADDED_BOTTOM, COMMENT_PAGE_ADDED_BOTTOM,
COMMENT_UPDATED,
REFRESH REFRESH
} }
} }

View File

@ -56,7 +56,7 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
Dialogs.buildDeleteCommentDialog(requireContext()) { delete -> Dialogs.buildDeleteCommentDialog(requireContext()) { delete ->
optionsViewModel.handle() optionsViewModel.handle()
if(delete) { if(delete) {
// remove comment viewModel.deleteComment(comment.id)
} }
}.show() }.show()
@ -106,25 +106,22 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
} }
} }
private val finishedEditingComment: Observer<Boolean?> = Observer { private val commentToEditObserver: Observer<Comment?> = Observer {
if(it == true) { EditCommentDialogFragment().show(childFragmentManager, EditCommentDialogFragment.TAG)
switchEditionModeUi(false)
}
} }
private val commentToEditObserver: Observer<Comment> = Observer {
switchEditionModeUi(true)
viewBinding.newCommentTextField.editText?.setText(it.textContent)
}
private fun setObservers() { private fun setObservers() {
viewModel.comments.observe(viewLifecycleOwner, commentsObserver) viewModel.comments.observe(viewLifecycleOwner, commentsObserver)
viewModel.noMoreContent.observe(viewLifecycleOwner, noMoreContentObserver) viewModel.noMoreContent.observe(viewLifecycleOwner, noMoreContentObserver)
viewModel.commentPosted.observe(viewLifecycleOwner, commentPostedObserver) viewModel.commentPosted.observe(viewLifecycleOwner, commentPostedObserver)
viewModel.commentToEdit.observe(viewLifecycleOwner, commentToEditObserver) viewModel.commentToEdit.observe(viewLifecycleOwner, commentToEditObserver)
viewModel.finishedEditingComment.observe(viewLifecycleOwner, finishedEditingComment) viewModel.error.observe(viewLifecycleOwner) {
if(it == true) {
Toast.makeText(requireContext(), "error", Toast.LENGTH_SHORT).show()
viewModel.error.postValue(null)
}
}
optionsViewModel.optionClicked.observe(viewLifecycleOwner, optionsObserver) optionsViewModel.optionClicked.observe(viewLifecycleOwner, optionsObserver)
} }

View File

@ -0,0 +1,107 @@
package com.isolaatti.posting.comments.ui
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import com.isolaatti.R
import com.isolaatti.databinding.FragmentEditCommentBinding
import com.isolaatti.posting.comments.presentation.CommentsViewModel
import com.isolaatti.utils.PicassoImagesPluginDef
import com.isolaatti.utils.UrlGen
import com.squareup.picasso.Picasso
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute
import io.noties.markwon.linkify.LinkifyPlugin
class EditCommentDialogFragment : DialogFragment() {
private val viewModel: CommentsViewModel by viewModels({requireParentFragment()})
private lateinit var binding: FragmentEditCommentBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.AlertDialogTransparent)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentEditCommentBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val markwon = Markwon.builder(requireContext())
.usePlugin(object: AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder
.imageDestinationProcessor(
ImageDestinationProcessorRelativeToAbsolute
.create("https://isolaatti.com/"))
}
})
.usePlugin(PicassoImagesPluginDef.picassoImagePlugin)
.usePlugin(LinkifyPlugin.create())
.build()
binding.comment.moreButton.visibility = View.GONE
binding.newCommentTextField.editText?.doOnTextChanged { text, start, before, count ->
binding.submitCommentButton.isEnabled = !text.isNullOrBlank()
}
binding.submitCommentButton.setOnClickListener {
val newContent = binding.newCommentTextField.editText?.text
if(newContent != null) {
binding.submitCommentButton.isEnabled = false
viewModel.editComment(newContent.toString())
}
}
viewModel.commentToEdit.observe(viewLifecycleOwner) { comment ->
if(comment == null) {
return@observe
}
binding.comment.also {
it.textViewUsername.text = comment.username
markwon.setMarkdown(it.postContent, comment.textContent)
Picasso.get().load(UrlGen.userProfileImage(comment.userId)).into(it.avatarPicture)
}
binding.newCommentTextField.editText?.setText(comment.textContent)
}
viewModel.error.observe(viewLifecycleOwner) {
if(it) {
Toast.makeText(requireContext(), "error", Toast.LENGTH_SHORT).show()
viewModel.error.postValue(true)
}
}
viewModel.finishedEditingComment.observe(viewLifecycleOwner) {
if(it == true) {
viewModel.finishedEditingComment.postValue(null)
requireDialog().dismiss()
} else if(it == false) {
binding.submitCommentButton.isEnabled = false
}
}
}
companion object {
const val TAG = "EditCommentDialogFragment"
}
}

View File

@ -49,7 +49,7 @@
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_to_refresh" android:id="@+id/swipe_to_refresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/topAppBar_layout"> app:layout_constraintTop_toBottomOf="@+id/topAppBar_layout">

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="bottom">
<include
android:id="@+id/comment"
layout="@layout/comment_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.bottomappbar.BottomAppBar
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
tools:ignore="BottomAppBar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/comment_actions"
android:layout_width="match_parent"
android:layout_height="40dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:orientation="horizontal">
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/newCommentTextField"
style="?attr/textInputOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:hint="@string/edit_comment"
app:boxCornerRadiusBottomEnd="20dp"
app:boxCornerRadiusBottomStart="20dp"
app:boxCornerRadiusTopEnd="20dp"
app:boxCornerRadiusTopStart="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/submitCommentButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/comment_actions">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/submitCommentButton"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_margin="8dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:enabled="false"
app:icon="@drawable/baseline_check_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/newCommentTextField" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.bottomappbar.BottomAppBar>
</LinearLayout>

View File

@ -74,4 +74,5 @@
<string name="save_snapshot">Save snapshot</string> <string name="save_snapshot">Save snapshot</string>
<string name="comment_posted">Comment posted!</string> <string name="comment_posted">Comment posted!</string>
<string name="comment_failed_to_post">Comment failed to post</string> <string name="comment_failed_to_post">Comment failed to post</string>
<string name="edit_comment">Edit comment</string>
</resources> </resources>

View File

@ -23,4 +23,11 @@
<item name="android:textSize">30sp</item> <item name="android:textSize">30sp</item>
</style> </style>
<style name="AlertDialogTransparent" parent="@style/Theme.Isolaatti">
<item name="windowNoTitle">true</item>
<item name="windowActionBar">true</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowBackground">#D9000000</item>
</style>
</resources> </resources>