notificaciones

This commit is contained in:
erik-everardo 2024-03-14 22:21:55 -06:00
parent bd994a17e8
commit 9667eafa49
8 changed files with 335 additions and 42 deletions

View File

@ -1,10 +1,15 @@
package com.isolaatti.notifications.data package com.isolaatti.notifications.data
import retrofit2.Call import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
interface NotificationsApi { interface NotificationsApi {
@GET("/api/Notifications/list") @GET("/api/Notifications/list")
fun getNotifications(@Query("after") after: Long?): Call<NotificationsDto> fun getNotifications(@Query("after") after: Long?): Call<NotificationsDto>
@POST("/api/Notifications/delete_many")
fun deleteNotifications(@Body ids: DeleteNotificationsDto): Call<Unit>
} }

View File

@ -1,6 +1,5 @@
package com.isolaatti.notifications.data package com.isolaatti.notifications.data
import com.google.gson.internal.LinkedTreeMap
import java.time.ZonedDateTime import java.time.ZonedDateTime
data class NotificationsDto( data class NotificationsDto(
@ -12,13 +11,7 @@ data class NotificationDto(
val date: ZonedDateTime, val date: ZonedDateTime,
val userId: Int, val userId: Int,
val read: Boolean, val read: Boolean,
val payload: NotificationPayload
)
data class NotificationPayload(
val type: String,
val authorId: Int,
val authorName: String?,
val intentData: String?,
val data: Map<String, String> val data: Map<String, String>
) )
data class DeleteNotificationsDto(val ids: List<Long>)

View File

@ -14,10 +14,11 @@ class NotificationsRepositoryImpl(private val notificationsApi: NotificationsApi
} }
override fun getNotifications(after: Long?): Flow<Resource<List<Notification>>> = flow { override fun getNotifications(after: Long?): Flow<Resource<List<Notification>>> = flow {
try { try {
emit(Resource.Loading())
val response = notificationsApi.getNotifications(after).awaitResponse() val response = notificationsApi.getNotifications(after).awaitResponse()
if(response.isSuccessful) { if(response.isSuccessful) {
emit(Resource.Success(response.body()!!.result.mapNotNull { Notification.fromDto(it) }))
} else { } else {
Log.e(LOG_TAG, "getNotifications(): Request is not successful, response code is ${response.code()}") Log.e(LOG_TAG, "getNotifications(): Request is not successful, response code is ${response.code()}")
emit(Resource.Error(Resource.Error.mapErrorCode(response.code()))) emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
@ -27,4 +28,21 @@ class NotificationsRepositoryImpl(private val notificationsApi: NotificationsApi
emit(Resource.Error(Resource.Error.ErrorType.OtherError)) emit(Resource.Error(Resource.Error.ErrorType.OtherError))
} }
} }
override fun deleteNotifications(vararg notification: Notification): Flow<Resource<Boolean>> = flow {
try {
emit(Resource.Loading())
val response = notificationsApi.deleteNotifications(DeleteNotificationsDto(notification.map { it.id })).awaitResponse()
if(response.isSuccessful) {
emit(Resource.Success(true))
} else {
Log.e(LOG_TAG, "deleteNotifications(): Request is not successful, response code is ${response.code()}")
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
}
} catch(e: Exception) {
Log.e(LOG_TAG, e.message.toString())
emit(Resource.Error(Resource.Error.ErrorType.OtherError))
}
}
} }

View File

@ -1,8 +1,6 @@
package com.isolaatti.notifications.domain package com.isolaatti.notifications.domain
import com.isolaatti.databinding.NotificationItemBinding
import com.isolaatti.notifications.data.NotificationDto import com.isolaatti.notifications.data.NotificationDto
import com.isolaatti.notifications.data.NotificationPayload
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ -11,14 +9,29 @@ class GenericNotification(id: Long, date: ZonedDateTime, userId: Int, read: Bool
var title: String? = null var title: String? = null
var message: String? = null var message: String? = null
override fun ingestPayload(notificationPayload: NotificationPayload) { override fun ingestPayload(data: Map<String, String>) {
} }
override fun bind(notificationBinding: NotificationItemBinding) { override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GenericNotification
if (title != other.title) return false
if (message != other.message) return false
return true
} }
override fun hashCode(): Int {
var result = title?.hashCode() ?: 0
result = 31 * result + (message?.hashCode() ?: 0)
return result
}
companion object { companion object {
const val TYPE = "generic" const val TYPE = "generic"
} }
@ -29,13 +42,39 @@ class LikeNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean
const val TYPE = "like" const val TYPE = "like"
} }
override fun ingestPayload(notificationPayload: NotificationPayload) { var likeId: String? = null
TODO("Not yet implemented") var postId: Long? = null
var authorId: Int? = null
var authorName: String? = null
override fun ingestPayload(data: Map<String, String>) {
likeId = data["likeId"]
postId = data["postId"]?.toLongOrNull()
authorId = data["authorId"]?.toIntOrNull()
authorName = data["authorName"]
} }
override fun bind(notificationBinding: NotificationItemBinding) { override fun equals(other: Any?): Boolean {
TODO("Not yet implemented") if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LikeNotification
if (likeId != other.likeId) return false
if (postId != other.postId) return false
if (authorId != other.authorId) return false
if (authorName != other.authorName) return false
return true
} }
override fun hashCode(): Int {
var result = likeId?.hashCode() ?: 0
result = 31 * result + (postId?.hashCode() ?: 0)
result = 31 * result + (authorId ?: 0)
result = 31 * result + (authorName?.hashCode() ?: 0)
return result
}
} }
class FollowNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) { class FollowNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) {
@ -44,12 +83,30 @@ class FollowNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boole
const val TYPE = "follower" const val TYPE = "follower"
} }
override fun ingestPayload(notificationPayload: NotificationPayload) { var followerName: String? = null
TODO("Not yet implemented") var followerUserId: Int? = null
override fun ingestPayload(data: Map<String, String>) {
followerName = data["followerName"]
followerUserId = data["followerUserId"]?.toIntOrNull()
} }
override fun bind(notificationBinding: NotificationItemBinding) { override fun equals(other: Any?): Boolean {
TODO("Not yet implemented") if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FollowNotification
if (followerName != other.followerName) return false
if (followerUserId != other.followerUserId) return false
return true
}
override fun hashCode(): Int {
var result = followerName?.hashCode() ?: 0
result = 31 * result + (followerUserId ?: 0)
return result
} }
} }
@ -61,13 +118,31 @@ abstract class Notification(
var read: Boolean var read: Boolean
) { ) {
abstract fun ingestPayload(notificationPayload: NotificationPayload) abstract fun ingestPayload(data: Map<String, String>)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Notification) return false
abstract fun bind(notificationBinding: NotificationItemBinding) if (id != other.id) return false
if (date != other.date) return false
if (userId != other.userId) return false
if (read != other.read) return false
if (other != this) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + date.hashCode()
result = 31 * result + userId
result = 31 * result + read.hashCode()
return result
}
companion object { companion object {
fun fromDto(notificationDto: NotificationDto): Notification? { fun fromDto(notificationDto: NotificationDto): Notification? {
return when(notificationDto.payload.type) { val type = notificationDto.data["type"]
return when(type) {
GenericNotification.TYPE -> { GenericNotification.TYPE -> {
GenericNotification( GenericNotification(
@ -76,7 +151,7 @@ abstract class Notification(
notificationDto.userId, notificationDto.userId,
notificationDto.read notificationDto.read
).apply { ).apply {
ingestPayload(notificationDto.payload) ingestPayload(notificationDto.data)
} }
} }
LikeNotification.TYPE -> { LikeNotification.TYPE -> {
@ -86,7 +161,7 @@ abstract class Notification(
notificationDto.userId, notificationDto.userId,
notificationDto.read notificationDto.read
).apply { ).apply {
ingestPayload(notificationDto.payload) ingestPayload(notificationDto.data)
} }
} }
FollowNotification.TYPE -> { FollowNotification.TYPE -> {
@ -96,7 +171,7 @@ abstract class Notification(
notificationDto.userId, notificationDto.userId,
notificationDto.read notificationDto.read
).apply { ).apply {
ingestPayload(notificationDto.payload) ingestPayload(notificationDto.data)
} }
} }
else -> null else -> null

View File

@ -5,4 +5,5 @@ import kotlinx.coroutines.flow.Flow
interface NotificationsRepository { interface NotificationsRepository {
fun getNotifications(after: Long?): Flow<Resource<List<Notification>>> fun getNotifications(after: Long?): Flow<Resource<List<Notification>>>
fun deleteNotifications(vararg notification: Notification): Flow<Resource<Boolean>>
} }

View File

@ -1,16 +1,23 @@
package com.isolaatti.notifications.presentation package com.isolaatti.notifications.presentation
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.load
import com.isolaatti.R
import com.isolaatti.databinding.NotificationItemBinding import com.isolaatti.databinding.NotificationItemBinding
import com.isolaatti.notifications.domain.FollowNotification
import com.isolaatti.notifications.domain.LikeNotification
import com.isolaatti.notifications.domain.Notification import com.isolaatti.notifications.domain.Notification
import com.isolaatti.utils.UrlGen
class NotificationsAdapter : ListAdapter<Notification, NotificationsAdapter.NotificationViewHolder>( class NotificationsAdapter(
diffCallback private val onNotificationClick: (notification: Notification) -> Unit,
) { private val onItemOptionsClick: (button: View, notification: Notification) -> Unit
) : ListAdapter<Notification, NotificationsAdapter.NotificationViewHolder>(diffCallback) {
inner class NotificationViewHolder(val notificationItemBinding: NotificationItemBinding) : ViewHolder(notificationItemBinding.root) inner class NotificationViewHolder(val notificationItemBinding: NotificationItemBinding) : ViewHolder(notificationItemBinding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
@ -18,19 +25,61 @@ class NotificationsAdapter : ListAdapter<Notification, NotificationsAdapter.Noti
} }
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) { override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) {
getItem(position).bind(holder.notificationItemBinding) val context = holder.notificationItemBinding.root.context
val notification = getItem(position)
holder.notificationItemBinding.root.setOnClickListener {
onNotificationClick(notification)
}
holder.notificationItemBinding.optionButton.setOnClickListener {
onItemOptionsClick(it, notification)
}
when(notification) {
is LikeNotification -> {
holder.notificationItemBinding.notificationTitle.text = context.getString(R.string.like_notification_title, notification.authorName)
holder.notificationItemBinding.notificationMessage.text = context.getString(R.string.like_notification_text)
val authorProfileImageUrl = notification.authorId?.let { UrlGen.userProfileImage(it, false) }
if(authorProfileImageUrl != null) {
holder.notificationItemBinding.notificationMainImage.load(authorProfileImageUrl){
fallback(R.drawable.baseline_person_24)
}
} else {
holder.notificationItemBinding.notificationMainImage.load(R.drawable.baseline_person_24)
}
holder.notificationItemBinding.notificationSecondaryImage.load(R.drawable.hands_clapping_solid)
}
is FollowNotification -> {
holder.notificationItemBinding.notificationTitle.text = context.getString(R.string.new_follower_notification_title, notification.followerName)
holder.notificationItemBinding.notificationMessage.text = context.getString(R.string.new_follower_notification_text)
val followerProfileImageUrl = notification.followerUserId?.let { UrlGen.userProfileImage(it, false) }
if(followerProfileImageUrl != null) {
holder.notificationItemBinding.notificationMainImage.load(followerProfileImageUrl) {
fallback(R.drawable.baseline_person_24)
}
} else {
holder.notificationItemBinding.notificationMainImage.load(R.drawable.baseline_person_24)
}
holder.notificationItemBinding.notificationSecondaryImage.load(R.drawable.baseline_star_24)
}
}
} }
companion object { companion object {
val diffCallback = object: DiffUtil.ItemCallback<Notification>() { val diffCallback = object: DiffUtil.ItemCallback<Notification>() {
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean { override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean {
TODO("Not yet implemented") return oldItem.id == newItem.id
} }
override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean { override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean {
TODO("Not yet implemented") return oldItem == newItem
} }
} }
} }
} }

View File

@ -1,11 +1,71 @@
package com.isolaatti.notifications.presentation package com.isolaatti.notifications.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.notifications.domain.Notification
import com.isolaatti.notifications.domain.NotificationsRepository import com.isolaatti.notifications.domain.NotificationsRepository
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class NotificationsViewModel @Inject constructor(private val notificationsRepository: NotificationsRepository) : ViewModel() { class NotificationsViewModel @Inject constructor(private val notificationsRepository: NotificationsRepository) : ViewModel() {
companion object {
const val LOG_TAG = "NotificationsViewModel"
}
val notifications: MutableLiveData<List<Notification>> = MutableLiveData()
val loading: MutableLiveData<Boolean> = MutableLiveData()
val error: MutableLiveData<Boolean> = MutableLiveData()
fun getData() {
viewModelScope.launch {
notificationsRepository.getNotifications(null).onEach {
when(it) {
is Resource.Error -> {
loading.postValue(false)
}
is Resource.Loading -> {
loading.postValue(true)
}
is Resource.Success -> {
loading.postValue(false)
notifications.postValue(it.data!!)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
private fun onDeleted(notification: Notification) {
val mutableList = notifications.value?.toMutableList()
val removed = mutableList?.remove(notification)
if(mutableList != null && removed == true) {
notifications.postValue(mutableList)
}
}
fun deleteNotification(notification: Notification) {
viewModelScope.launch {
notificationsRepository.deleteNotifications(notification).onEach {
when(it) {
is Resource.Error -> {
error.postValue(true)
}
is Resource.Loading -> {
error.postValue(false)
}
is Resource.Success -> {
error.postValue(false)
onDeleted(notification)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
} }

View File

@ -1,33 +1,125 @@
package com.isolaatti.notifications.ui package com.isolaatti.notifications.ui
import androidx.lifecycle.ViewModelProvider
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.databinding.FragmentNotificationsBinding
import com.isolaatti.notifications.domain.FollowNotification
import com.isolaatti.notifications.domain.LikeNotification
import com.isolaatti.notifications.domain.Notification
import com.isolaatti.notifications.presentation.NotificationsAdapter
import com.isolaatti.notifications.presentation.NotificationsViewModel import com.isolaatti.notifications.presentation.NotificationsViewModel
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity
import com.isolaatti.profile.ui.ProfileActivity
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
@AndroidEntryPoint
class NotificationsFragment : Fragment() { class NotificationsFragment : Fragment() {
companion object { companion object {
fun newInstance() = NotificationsFragment() fun newInstance() = NotificationsFragment()
} }
private lateinit var viewModel: NotificationsViewModel private lateinit var binding: FragmentNotificationsBinding
private val viewModel: NotificationsViewModel by viewModels()
private var adapter: NotificationsAdapter? = null
private fun showDeleteNotificationDialog(notification: Notification) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.delete_notification)
.setMessage(R.string.delete_notification_dialog_message)
.setPositiveButton(R.string.accept) { _, _ ->
viewModel.deleteNotification(notification)
}
.setNegativeButton(R.string.no, null)
.show()
}
private val onItemOptionsClick: (button: View, notification: Notification) -> Unit = { button, notification ->
val popupMenu = PopupMenu(requireContext(), button)
popupMenu.inflate(R.menu.notification_menu)
popupMenu.setOnMenuItemClickListener {
when(it.itemId) {
R.id.delete_notification -> {
showDeleteNotificationDialog(notification)
true
}
else -> false
}
}
popupMenu.show()
}
private val onNotificationClick: (notification: Notification) -> Unit = { notification ->
when(notification) {
is LikeNotification -> {
notification.postId?.also { postId ->
PostViewerActivity.startActivity(requireContext(), postId)
}
}
is FollowNotification -> {
notification.followerUserId?.also { followerUserId ->
ProfileActivity.startActivity(requireContext(), followerUserId)
}
}
}
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
return inflater.inflate(R.layout.fragment_notifications, container, false) binding = FragmentNotificationsBinding.inflate(inflater, container, false)
return binding.root
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this).get(NotificationsViewModel::class.java)
// TODO: Use the ViewModel
adapter = NotificationsAdapter(onNotificationClick, onItemOptionsClick)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
viewModel.getData()
setupObservers()
setupListeners()
} }
private fun setupListeners() {
binding.swipeToRefresh.setOnRefreshListener {
viewModel.getData()
}
}
private fun setupObservers() {
viewModel.notifications.observe(viewLifecycleOwner) {
adapter?.submitList(it)
}
viewModel.loading.observe(viewLifecycleOwner) {
binding.swipeToRefresh.isRefreshing = it
}
viewModel.error.observe(viewLifecycleOwner) {
if(it){
Toast.makeText(requireContext(), R.string.error_making_request, Toast.LENGTH_SHORT).show()
viewModel.error.value = false
}
}
}
} }