feat(notifications): Various improvements

This commit is contained in:
Peter Vacho 2025-01-04 17:01:20 +01:00
parent ef08d6ddd3
commit 3ae9081787
Signed by: school
GPG key ID: 8CFC3837052871B4
4 changed files with 101 additions and 39 deletions

View file

@ -95,7 +95,8 @@ class CalendarActivity : AppCompatActivity() {
val invitedEvents = RetrofitClient.eventsService.userEventsInvited(
userId = userId,
startFrom = startFrom,
startTo = endTo
startTo = endTo,
inviteStatus = "accepted"
)
val allEvents = userEvents + invitedEvents

View file

@ -15,10 +15,12 @@ import com.p_vacho.neat_calendar.MyApplication
import com.p_vacho.neat_calendar.R
import com.p_vacho.neat_calendar.adapters.NotificationAdapter
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.EventResponse
import com.p_vacho.neat_calendar.api.models.InvitationResponse
import com.p_vacho.neat_calendar.api.models.NotificationResponse
import com.p_vacho.neat_calendar.api.models.UserResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONException
@ -33,6 +35,7 @@ class NotificationsActivity : AppCompatActivity() {
private lateinit var notifications: MutableList<NotificationResponse>
private lateinit var invitations: MutableMap<String, InvitationResponse> // invitation id -> invitation
private lateinit var events: MutableMap<String, EventResponse> // event id -> event
private val users: MutableMap<String, UserResponse?> = mutableMapOf() // user id -> user (or null if not found)
override fun onCreate(savedInstanceState: Bundle?) {
@ -53,19 +56,30 @@ class NotificationsActivity : AppCompatActivity() {
rvNotifications.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch {
notifications = fetchNotifications().toMutableList()
invitations = fetchInvitations().toMutableMap()
// Use async to fetch data in parallel
val notificationsDeferred = async { fetchNotifications() }
val invitationsDeferred = async { fetchInvitations() }
val eventsDeferred = async { fetchEvents() }
// Wait for all results
notifications = notificationsDeferred.await().toMutableList()
invitations = invitationsDeferred.await().toMutableMap()
events = eventsDeferred.await().toMutableMap()
rvNotifications.adapter = NotificationAdapter(
notifications,
::handleNotificationAction,
::handleNotificationClick,
::getInvitationData,
::getUserData
::getUserData,
::getEventData,
)
}
}
/**
* Fetch all notifications for the currently logged in user
*/
private suspend fun fetchNotifications(): List<NotificationResponse> {
val userId = (application as MyApplication).tokenManager.userId
if (userId == null) {
@ -78,6 +92,30 @@ class NotificationsActivity : AppCompatActivity() {
}
}
/**
* Fetch all the events that the user is already attending (accepted invite),
* the events that the user has a pending invite for, and user's
* owned events (in case of invitation confirmations).
*/
private suspend fun fetchEvents(): Map<String, EventResponse> {
val userId = (application as MyApplication).tokenManager.userId
if (userId == null) {
finish()
return emptyMap()
}
val fetchedEvents = withContext(Dispatchers.IO) {
val invitedDeferred = async { RetrofitClient.eventsService.userEventsInvited(userId) }
val ownedDeferred = async { RetrofitClient.eventsService.userEvents(userId) }
val invitedEvents = invitedDeferred.await()
val ownedEvents = ownedDeferred.await()
(invitedEvents + ownedEvents).distinctBy { it.id }
}
return fetchedEvents.associateBy { it.id }
}
private suspend fun fetchInvitations(): Map<String, InvitationResponse> {
val userId = (application as MyApplication).tokenManager.userId
if (userId == null) {
@ -85,9 +123,15 @@ class NotificationsActivity : AppCompatActivity() {
return emptyMap()
}
// Fetch the lists in parallel
val fetchedInvitations = withContext(Dispatchers.IO) {
RetrofitClient.invitationService.getIncomingInvitations(userId)
val incomingDeferred = async { RetrofitClient.invitationService.getIncomingInvitations(userId) }
val ownedDeferred = async { RetrofitClient.invitationService.getInvitations(userId) }
val incomingInvitations = incomingDeferred.await()
val ownedInvitations = ownedDeferred.await()
(incomingInvitations + ownedInvitations).distinctBy { it.id }
}
return fetchedInvitations.associateBy { it.id }
}
@ -95,6 +139,16 @@ class NotificationsActivity : AppCompatActivity() {
val ret = invitations[invitationId]
if (ret == null) {
Log.w("NotificationsActivity", "NotificationAdapter requested unknown invitation: $invitationId")
Log.w("NotificationsActivity", "Known invitations: $invitations")
}
return ret
}
private fun getEventData(eventId: String, rvPosition: Int): EventResponse? {
val ret = events[eventId]
if (ret == null) {
Log.w("NotificationsActivity", "NotificationAdapter requested unknown event: $eventId")
Log.w("NotificationsActivity", "Known events: $events")
}
return ret
}
@ -116,15 +170,14 @@ class NotificationsActivity : AppCompatActivity() {
}
} catch (e: HttpException) {
if (e.code() != 404) { throw e }
val errorBody = e.response()?.errorBody()?.string()
if (errorBody == null) { throw e }
val errorBody = e.response()?.errorBody()?.string() ?: throw e
try {
val jsonObj = JSONObject(errorBody)
val errDetail = jsonObj.optString("detail")
if (errDetail != "No such user") { throw e }
} catch (jsonParseExc: JSONException) { throw e }
Log.e("NotificationsActivity", "Failed to fetch user: $userId", e)
Log.e("NotificationsActivity", "Failed to fetch user: $userId")
users[userId] = null // Cache null for non-existing users
// No need for an adapter update here, null is already the default
@ -139,7 +192,7 @@ class NotificationsActivity : AppCompatActivity() {
NotificationAdapter.Action.ACCEPT -> {
lifecycleScope.launch(Dispatchers.IO) {
val invitationId = notification.data
RetrofitClient.invitationService.acceptInvitation(invitationId)
invitations[invitationId] = RetrofitClient.invitationService.acceptInvitation(invitationId)
withContext(Dispatchers.Main) {
Toast.makeText(this@NotificationsActivity, "Invitation accepted", Toast.LENGTH_SHORT).show()
@ -152,7 +205,8 @@ class NotificationsActivity : AppCompatActivity() {
// but otherwise, we'll need to trigger an item update ourselves, to make sure that
// the accept/decline buttons are removed
rvNotifications.adapter!!.notifyItemChanged(position)
} }
}
}
}
}
NotificationAdapter.Action.DECLINE -> {
@ -188,8 +242,7 @@ class NotificationsActivity : AppCompatActivity() {
RetrofitClient.notificationsService.markNotificationRead(notification.id)
notifications[position] = updatedNotification
val adapter = rvNotifications.adapter as NotificationAdapter
adapter.notifyItemChanged(position)
rvNotifications.adapter!!.notifyItemChanged(position)
if (sendToast) {
Toast.makeText(this@NotificationsActivity, "Marked as read", Toast.LENGTH_SHORT)

View file

@ -7,6 +7,7 @@ import android.widget.ImageButton
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.p_vacho.neat_calendar.R
import com.p_vacho.neat_calendar.api.models.EventResponse
import com.p_vacho.neat_calendar.api.models.InvitationResponse
import com.p_vacho.neat_calendar.api.models.NotificationResponse
import com.p_vacho.neat_calendar.api.models.UserResponse
@ -22,6 +23,7 @@ class NotificationAdapter(
private val onNotificationClick: (NotificationResponse, Int) -> Unit,
private val getInvitationData: (String, Int) -> InvitationResponse?,
private val getUserData: (String, Int) -> UserResponse?,
private val getEventData: (String, Int) -> EventResponse?,
) : RecyclerView.Adapter<NotificationAdapter.NotificationViewHolder>() {
enum class Action {
@ -88,19 +90,22 @@ class NotificationAdapter(
}
val user = getUserData(usernameId, position)
val username = user?.username ?: "Unknown User"
val username = user?.username ?: "unknown-user"
val statusSuffix = when (invitation.status) {
"accepted" -> " [already accepted]"
"declined" -> " [already declined]"
"pending" -> ""
else -> throw IllegalStateException("Unexpected invitation status: ${invitation.status} for invitation ID: ${invitation.id}")
}
// We can't access events of incoming declined invitations
val event = if (notification.message == "new-invitation" && invitation.status == "declined")
null else getEventData(invitation.event_id, position)
val event_name = event?.title ?: "unknown-title"
return when (notification.message) {
"new-invitation" -> "You have received an event invitation from @$username$statusSuffix"
"invitation-accepted" -> "@$username has accepted your event invitation$statusSuffix"
"invitation-declined" -> "@$username has declined your event invitation$statusSuffix"
"new-invitation" -> when (invitation.status) {
"pending" -> "You have received an event invitation ($event_name) from @$username"
"accepted" -> "You have received an event invitation ($event_name) from @$username [already accepted]"
"declined" -> "You have received an event invitation from @$username [already declined]"
else -> throw IllegalStateException("Unexpected invitation status: ${invitation.status} for invitation ID: ${invitation.id}")
}
"invitation-accepted" -> "@$username has accepted your event invitation ($event_name) "
"invitation-declined" -> "@$username has declined your event invitation ($event_name) "
else -> throw IllegalArgumentException("Unexpected notification message: ${notification.message}")
}
}
@ -111,12 +116,24 @@ class NotificationAdapter(
notification: NotificationResponse,
position: Int
) {
// Set the listeners first, even though not all of these buttons
// will actually be clickable/visible, it's easier to do here once
holder.acceptButton.setOnClickListener {
onActionClick(notification, Action.ACCEPT, position)
}
holder.declineButton.setOnClickListener {
onActionClick(notification, Action.DECLINE, position)
}
holder.viewEventButton.setOnClickListener {
onActionClick(notification, Action.VIEW_EVENT, position)
}
if (notification.event_type == "invitation") {
val invitation = getInvitationData(notification.data, position)
if (invitation == null) {
// No buttons for deleted invitations
holder.invitationActions.visibility = View.GONE
return
}
@ -129,24 +146,18 @@ class NotificationAdapter(
holder.acceptButton.visibility = View.VISIBLE
holder.declineButton.visibility = View.VISIBLE
holder.viewEventButton.visibility = View.VISIBLE
holder.acceptButton.setOnClickListener {
onActionClick(notification, Action.ACCEPT, position)
}
holder.declineButton.setOnClickListener {
onActionClick(notification, Action.DECLINE, position)
}
}
"accepted", "declined" -> {
"accepted" -> {
// Show only View button
holder.invitationActions.visibility = View.VISIBLE
holder.acceptButton.visibility = View.GONE
holder.declineButton.visibility = View.GONE
holder.viewEventButton.visibility = View.VISIBLE
holder.viewEventButton.setOnClickListener {
onActionClick(notification, Action.VIEW_EVENT, position)
}
}
"declined" -> {
// No buttons, can't view events from declined invites
holder.invitationActions.visibility = View.GONE
}
else -> throw IllegalStateException("Unexpected invitation status: ${invitation.status} for invite ID: ${invitation.id}")
}
@ -157,10 +168,6 @@ class NotificationAdapter(
holder.acceptButton.visibility = View.GONE
holder.declineButton.visibility = View.GONE
holder.viewEventButton.visibility = View.VISIBLE
holder.viewEventButton.setOnClickListener {
onActionClick(notification, Action.VIEW_EVENT, position)
}
}
else -> {
throw IllegalArgumentException("Unexpected notification message: ${notification.message}")

View file

@ -31,7 +31,8 @@ interface EventsService {
@Query("start_from") startFrom: OffsetDateTime? = null,
@Query("start_to") startTo: OffsetDateTime? = null,
@Query("end_from") endFrom: OffsetDateTime? = null,
@Query("end_to") endTo: OffsetDateTime? = null
@Query("end_to") endTo: OffsetDateTime? = null,
@Query("inviteStatus") inviteStatus: String? = null, // "pending" / "accepted" / null (both)
): List<EventResponse>
@DELETE("events/{event_id}")