Compare commits

...

2 commits

4 changed files with 101 additions and 39 deletions

View file

@ -95,7 +95,8 @@ class CalendarActivity : AppCompatActivity() {
val invitedEvents = RetrofitClient.eventsService.userEventsInvited( val invitedEvents = RetrofitClient.eventsService.userEventsInvited(
userId = userId, userId = userId,
startFrom = startFrom, startFrom = startFrom,
startTo = endTo startTo = endTo,
inviteStatus = "accepted"
) )
val allEvents = userEvents + invitedEvents 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.R
import com.p_vacho.neat_calendar.adapters.NotificationAdapter import com.p_vacho.neat_calendar.adapters.NotificationAdapter
import com.p_vacho.neat_calendar.api.RetrofitClient 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.InvitationResponse
import com.p_vacho.neat_calendar.api.models.NotificationResponse import com.p_vacho.neat_calendar.api.models.NotificationResponse
import com.p_vacho.neat_calendar.api.models.UserResponse import com.p_vacho.neat_calendar.api.models.UserResponse
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONException import org.json.JSONException
@ -33,6 +35,7 @@ class NotificationsActivity : AppCompatActivity() {
private lateinit var notifications: MutableList<NotificationResponse> private lateinit var notifications: MutableList<NotificationResponse>
private lateinit var invitations: MutableMap<String, InvitationResponse> // invitation id -> invitation 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) private val users: MutableMap<String, UserResponse?> = mutableMapOf() // user id -> user (or null if not found)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -53,19 +56,30 @@ class NotificationsActivity : AppCompatActivity() {
rvNotifications.layoutManager = LinearLayoutManager(this) rvNotifications.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch { lifecycleScope.launch {
notifications = fetchNotifications().toMutableList() // Use async to fetch data in parallel
invitations = fetchInvitations().toMutableMap() 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( rvNotifications.adapter = NotificationAdapter(
notifications, notifications,
::handleNotificationAction, ::handleNotificationAction,
::handleNotificationClick, ::handleNotificationClick,
::getInvitationData, ::getInvitationData,
::getUserData ::getUserData,
::getEventData,
) )
} }
} }
/**
* Fetch all notifications for the currently logged in user
*/
private suspend fun fetchNotifications(): List<NotificationResponse> { private suspend fun fetchNotifications(): List<NotificationResponse> {
val userId = (application as MyApplication).tokenManager.userId val userId = (application as MyApplication).tokenManager.userId
if (userId == null) { 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> { private suspend fun fetchInvitations(): Map<String, InvitationResponse> {
val userId = (application as MyApplication).tokenManager.userId val userId = (application as MyApplication).tokenManager.userId
if (userId == null) { if (userId == null) {
@ -85,9 +123,15 @@ class NotificationsActivity : AppCompatActivity() {
return emptyMap() return emptyMap()
} }
// Fetch the lists in parallel
val fetchedInvitations = withContext(Dispatchers.IO) { 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 } return fetchedInvitations.associateBy { it.id }
} }
@ -95,6 +139,16 @@ class NotificationsActivity : AppCompatActivity() {
val ret = invitations[invitationId] val ret = invitations[invitationId]
if (ret == null) { if (ret == null) {
Log.w("NotificationsActivity", "NotificationAdapter requested unknown invitation: $invitationId") 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 return ret
} }
@ -116,15 +170,14 @@ class NotificationsActivity : AppCompatActivity() {
} }
} catch (e: HttpException) { } catch (e: HttpException) {
if (e.code() != 404) { throw e } if (e.code() != 404) { throw e }
val errorBody = e.response()?.errorBody()?.string() val errorBody = e.response()?.errorBody()?.string() ?: throw e
if (errorBody == null) { throw e }
try { try {
val jsonObj = JSONObject(errorBody) val jsonObj = JSONObject(errorBody)
val errDetail = jsonObj.optString("detail") val errDetail = jsonObj.optString("detail")
if (errDetail != "No such user") { throw e } if (errDetail != "No such user") { throw e }
} catch (jsonParseExc: JSONException) { 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 users[userId] = null // Cache null for non-existing users
// No need for an adapter update here, null is already the default // No need for an adapter update here, null is already the default
@ -139,7 +192,7 @@ class NotificationsActivity : AppCompatActivity() {
NotificationAdapter.Action.ACCEPT -> { NotificationAdapter.Action.ACCEPT -> {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val invitationId = notification.data val invitationId = notification.data
RetrofitClient.invitationService.acceptInvitation(invitationId) invitations[invitationId] = RetrofitClient.invitationService.acceptInvitation(invitationId)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Toast.makeText(this@NotificationsActivity, "Invitation accepted", Toast.LENGTH_SHORT).show() 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 // but otherwise, we'll need to trigger an item update ourselves, to make sure that
// the accept/decline buttons are removed // the accept/decline buttons are removed
rvNotifications.adapter!!.notifyItemChanged(position) rvNotifications.adapter!!.notifyItemChanged(position)
} } }
}
} }
} }
NotificationAdapter.Action.DECLINE -> { NotificationAdapter.Action.DECLINE -> {
@ -188,8 +242,7 @@ class NotificationsActivity : AppCompatActivity() {
RetrofitClient.notificationsService.markNotificationRead(notification.id) RetrofitClient.notificationsService.markNotificationRead(notification.id)
notifications[position] = updatedNotification notifications[position] = updatedNotification
val adapter = rvNotifications.adapter as NotificationAdapter rvNotifications.adapter!!.notifyItemChanged(position)
adapter.notifyItemChanged(position)
if (sendToast) { if (sendToast) {
Toast.makeText(this@NotificationsActivity, "Marked as read", Toast.LENGTH_SHORT) 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 android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.p_vacho.neat_calendar.R 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.InvitationResponse
import com.p_vacho.neat_calendar.api.models.NotificationResponse import com.p_vacho.neat_calendar.api.models.NotificationResponse
import com.p_vacho.neat_calendar.api.models.UserResponse import com.p_vacho.neat_calendar.api.models.UserResponse
@ -22,6 +23,7 @@ class NotificationAdapter(
private val onNotificationClick: (NotificationResponse, Int) -> Unit, private val onNotificationClick: (NotificationResponse, Int) -> Unit,
private val getInvitationData: (String, Int) -> InvitationResponse?, private val getInvitationData: (String, Int) -> InvitationResponse?,
private val getUserData: (String, Int) -> UserResponse?, private val getUserData: (String, Int) -> UserResponse?,
private val getEventData: (String, Int) -> EventResponse?,
) : RecyclerView.Adapter<NotificationAdapter.NotificationViewHolder>() { ) : RecyclerView.Adapter<NotificationAdapter.NotificationViewHolder>() {
enum class Action { enum class Action {
@ -88,19 +90,22 @@ class NotificationAdapter(
} }
val user = getUserData(usernameId, position) val user = getUserData(usernameId, position)
val username = user?.username ?: "Unknown User" val username = user?.username ?: "unknown-user"
val statusSuffix = when (invitation.status) { // We can't access events of incoming declined invitations
"accepted" -> " [already accepted]" val event = if (notification.message == "new-invitation" && invitation.status == "declined")
"declined" -> " [already declined]" null else getEventData(invitation.event_id, position)
"pending" -> "" val event_name = event?.title ?: "unknown-title"
else -> throw IllegalStateException("Unexpected invitation status: ${invitation.status} for invitation ID: ${invitation.id}")
}
return when (notification.message) { return when (notification.message) {
"new-invitation" -> "You have received an event invitation from @$username$statusSuffix" "new-invitation" -> when (invitation.status) {
"invitation-accepted" -> "@$username has accepted your event invitation$statusSuffix" "pending" -> "You have received an event invitation ($event_name) from @$username"
"invitation-declined" -> "@$username has declined your event invitation$statusSuffix" "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}") else -> throw IllegalArgumentException("Unexpected notification message: ${notification.message}")
} }
} }
@ -111,12 +116,24 @@ class NotificationAdapter(
notification: NotificationResponse, notification: NotificationResponse,
position: Int 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") { if (notification.event_type == "invitation") {
val invitation = getInvitationData(notification.data, position) val invitation = getInvitationData(notification.data, position)
if (invitation == null) { if (invitation == null) {
// No buttons for deleted invitations // No buttons for deleted invitations
holder.invitationActions.visibility = View.GONE holder.invitationActions.visibility = View.GONE
return return
} }
@ -129,24 +146,18 @@ class NotificationAdapter(
holder.acceptButton.visibility = View.VISIBLE holder.acceptButton.visibility = View.VISIBLE
holder.declineButton.visibility = View.VISIBLE holder.declineButton.visibility = View.VISIBLE
holder.viewEventButton.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 // Show only View button
holder.invitationActions.visibility = View.VISIBLE holder.invitationActions.visibility = View.VISIBLE
holder.acceptButton.visibility = View.GONE holder.acceptButton.visibility = View.GONE
holder.declineButton.visibility = View.GONE holder.declineButton.visibility = View.GONE
holder.viewEventButton.visibility = View.VISIBLE 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}") 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.acceptButton.visibility = View.GONE
holder.declineButton.visibility = View.GONE holder.declineButton.visibility = View.GONE
holder.viewEventButton.visibility = View.VISIBLE holder.viewEventButton.visibility = View.VISIBLE
holder.viewEventButton.setOnClickListener {
onActionClick(notification, Action.VIEW_EVENT, position)
}
} }
else -> { else -> {
throw IllegalArgumentException("Unexpected notification message: ${notification.message}") throw IllegalArgumentException("Unexpected notification message: ${notification.message}")

View file

@ -31,7 +31,8 @@ interface EventsService {
@Query("start_from") startFrom: OffsetDateTime? = null, @Query("start_from") startFrom: OffsetDateTime? = null,
@Query("start_to") startTo: OffsetDateTime? = null, @Query("start_to") startTo: OffsetDateTime? = null,
@Query("end_from") endFrom: 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> ): List<EventResponse>
@DELETE("events/{event_id}") @DELETE("events/{event_id}")