feat(notifications): Various improvements
This commit is contained in:
parent
ef08d6ddd3
commit
3ae9081787
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
Loading…
Reference in a new issue