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
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface NotificationsApi {
@GET("/api/Notifications/list")
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
import com.google.gson.internal.LinkedTreeMap
import java.time.ZonedDateTime
data class NotificationsDto(
@ -12,13 +11,7 @@ data class NotificationDto(
val date: ZonedDateTime,
val userId: Int,
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>
)
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 {
try {
emit(Resource.Loading())
val response = notificationsApi.getNotifications(after).awaitResponse()
if(response.isSuccessful) {
emit(Resource.Success(response.body()!!.result.mapNotNull { Notification.fromDto(it) }))
} else {
Log.e(LOG_TAG, "getNotifications(): Request is not successful, response code is ${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))
}
}
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
import com.isolaatti.databinding.NotificationItemBinding
import com.isolaatti.notifications.data.NotificationDto
import com.isolaatti.notifications.data.NotificationPayload
import java.time.ZonedDateTime
@ -11,14 +9,29 @@ class GenericNotification(id: Long, date: ZonedDateTime, userId: Int, read: Bool
var title: 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 {
const val TYPE = "generic"
}
@ -29,13 +42,39 @@ class LikeNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean
const val TYPE = "like"
}
override fun ingestPayload(notificationPayload: NotificationPayload) {
TODO("Not yet implemented")
var likeId: String? = null
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) {
TODO("Not yet implemented")
override fun equals(other: Any?): Boolean {
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) {
@ -44,12 +83,30 @@ class FollowNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boole
const val TYPE = "follower"
}
override fun ingestPayload(notificationPayload: NotificationPayload) {
TODO("Not yet implemented")
var followerName: String? = null
var followerUserId: Int? = null
override fun ingestPayload(data: Map<String, String>) {
followerName = data["followerName"]
followerUserId = data["followerUserId"]?.toIntOrNull()
}
override fun bind(notificationBinding: NotificationItemBinding) {
TODO("Not yet implemented")
override fun equals(other: Any?): Boolean {
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
) {
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 {
fun fromDto(notificationDto: NotificationDto): Notification? {
return when(notificationDto.payload.type) {
val type = notificationDto.data["type"]
return when(type) {
GenericNotification.TYPE -> {
GenericNotification(
@ -76,7 +151,7 @@ abstract class Notification(
notificationDto.userId,
notificationDto.read
).apply {
ingestPayload(notificationDto.payload)
ingestPayload(notificationDto.data)
}
}
LikeNotification.TYPE -> {
@ -86,7 +161,7 @@ abstract class Notification(
notificationDto.userId,
notificationDto.read
).apply {
ingestPayload(notificationDto.payload)
ingestPayload(notificationDto.data)
}
}
FollowNotification.TYPE -> {
@ -96,7 +171,7 @@ abstract class Notification(
notificationDto.userId,
notificationDto.read
).apply {
ingestPayload(notificationDto.payload)
ingestPayload(notificationDto.data)
}
}
else -> null

View File

@ -5,4 +5,5 @@ import kotlinx.coroutines.flow.Flow
interface NotificationsRepository {
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
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.load
import com.isolaatti.R
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.utils.UrlGen
class NotificationsAdapter : ListAdapter<Notification, NotificationsAdapter.NotificationViewHolder>(
diffCallback
) {
class NotificationsAdapter(
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)
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) {
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 {
val diffCallback = object: DiffUtil.ItemCallback<Notification>() {
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 {
TODO("Not yet implemented")
}
return oldItem == newItem
}
}
}
}

View File

@ -1,11 +1,71 @@
package com.isolaatti.notifications.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.notifications.domain.Notification
import com.isolaatti.notifications.domain.NotificationsRepository
import com.isolaatti.utils.Resource
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
@HiltViewModel
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
import androidx.lifecycle.ViewModelProvider
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
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.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.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() {
companion object {
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(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_notifications, container, false)
binding = FragmentNotificationsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProvider(this).get(NotificationsViewModel::class.java)
// TODO: Use the ViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
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
}
}
}
}