diff --git a/app/src/main/java/com/isolaatti/notifications/data/NotificationsApi.kt b/app/src/main/java/com/isolaatti/notifications/data/NotificationsApi.kt index 5d2d850..d30a964 100644 --- a/app/src/main/java/com/isolaatti/notifications/data/NotificationsApi.kt +++ b/app/src/main/java/com/isolaatti/notifications/data/NotificationsApi.kt @@ -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 + + @POST("/api/Notifications/delete_many") + fun deleteNotifications(@Body ids: DeleteNotificationsDto): Call } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/notifications/data/NotificationsDto.kt b/app/src/main/java/com/isolaatti/notifications/data/NotificationsDto.kt index 5c762f9..6f7a553 100644 --- a/app/src/main/java/com/isolaatti/notifications/data/NotificationsDto.kt +++ b/app/src/main/java/com/isolaatti/notifications/data/NotificationsDto.kt @@ -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 ) + +data class DeleteNotificationsDto(val ids: List) \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/notifications/data/NotificationsRepositoryImpl.kt b/app/src/main/java/com/isolaatti/notifications/data/NotificationsRepositoryImpl.kt index f2e16ec..dd66bc4 100644 --- a/app/src/main/java/com/isolaatti/notifications/data/NotificationsRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/notifications/data/NotificationsRepositoryImpl.kt @@ -14,10 +14,11 @@ class NotificationsRepositoryImpl(private val notificationsApi: NotificationsApi } override fun getNotifications(after: Long?): Flow>> = 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> = 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)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/notifications/domain/Notification.kt b/app/src/main/java/com/isolaatti/notifications/domain/Notification.kt index ab8a884..50b914d 100644 --- a/app/src/main/java/com/isolaatti/notifications/domain/Notification.kt +++ b/app/src/main/java/com/isolaatti/notifications/domain/Notification.kt @@ -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) { } - 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) { + 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) { + 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) + 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 diff --git a/app/src/main/java/com/isolaatti/notifications/domain/NotificationsRepository.kt b/app/src/main/java/com/isolaatti/notifications/domain/NotificationsRepository.kt index 5a4d9c4..26bb6af 100644 --- a/app/src/main/java/com/isolaatti/notifications/domain/NotificationsRepository.kt +++ b/app/src/main/java/com/isolaatti/notifications/domain/NotificationsRepository.kt @@ -5,4 +5,5 @@ import kotlinx.coroutines.flow.Flow interface NotificationsRepository { fun getNotifications(after: Long?): Flow>> + fun deleteNotifications(vararg notification: Notification): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsAdapter.kt b/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsAdapter.kt index 38e4a6c..4591419 100644 --- a/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsAdapter.kt +++ b/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsAdapter.kt @@ -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( - diffCallback -) { +class NotificationsAdapter( + private val onNotificationClick: (notification: Notification) -> Unit, + private val onItemOptionsClick: (button: View, notification: Notification) -> Unit +) : ListAdapter(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 { + 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() { 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 } - } } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsViewModel.kt b/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsViewModel.kt index 816c125..c07f869 100644 --- a/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsViewModel.kt +++ b/app/src/main/java/com/isolaatti/notifications/presentation/NotificationsViewModel.kt @@ -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> = MutableLiveData() + val loading: MutableLiveData = MutableLiveData() + val error: MutableLiveData = 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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/notifications/ui/NotificationsFragment.kt b/app/src/main/java/com/isolaatti/notifications/ui/NotificationsFragment.kt index 6f77631..c561770 100644 --- a/app/src/main/java/com/isolaatti/notifications/ui/NotificationsFragment.kt +++ b/app/src/main/java/com/isolaatti/notifications/ui/NotificationsFragment.kt @@ -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 + } + } + } } \ No newline at end of file