From 3ae9081787e967333cee8c13756d2b1c5203a616 Mon Sep 17 00:00:00 2001 From: Peter Vacho Date: Sat, 4 Jan 2025 17:01:20 +0100 Subject: [PATCH] feat(notifications): Various improvements --- .../activities/CalendarActivity.kt | 3 +- .../activities/NotificationsActivity.kt | 75 ++++++++++++++++--- .../adapters/NotificationAdapter.kt | 59 ++++++++------- .../api/services/EventsService.kt | 3 +- 4 files changed, 101 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt b/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt index a0d53c3..847b843 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt @@ -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 diff --git a/app/src/main/java/com/p_vacho/neat_calendar/activities/NotificationsActivity.kt b/app/src/main/java/com/p_vacho/neat_calendar/activities/NotificationsActivity.kt index 6baa624..80cb277 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/activities/NotificationsActivity.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/activities/NotificationsActivity.kt @@ -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 private lateinit var invitations: MutableMap // invitation id -> invitation + private lateinit var events: MutableMap // event id -> event private val users: MutableMap = 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 { 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 { + 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 { 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) diff --git a/app/src/main/java/com/p_vacho/neat_calendar/adapters/NotificationAdapter.kt b/app/src/main/java/com/p_vacho/neat_calendar/adapters/NotificationAdapter.kt index 26ed47a..ce55258 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/adapters/NotificationAdapter.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/adapters/NotificationAdapter.kt @@ -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() { 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}") diff --git a/app/src/main/java/com/p_vacho/neat_calendar/api/services/EventsService.kt b/app/src/main/java/com/p_vacho/neat_calendar/api/services/EventsService.kt index e3890d6..e81cb20 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/api/services/EventsService.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/api/services/EventsService.kt @@ -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 @DELETE("events/{event_id}")