feat(notifications): Fetch users & other necessary data

This commit is contained in:
Peter Vacho 2025-01-04 14:13:50 +01:00
parent 046da599b7
commit 7a0f3cea06
Signed by: school
GPG key ID: 8CFC3837052871B4
2 changed files with 144 additions and 84 deletions

View file

@ -1,6 +1,7 @@
package com.p_vacho.neat_calendar.activities package com.p_vacho.neat_calendar.activities
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.Toast import android.widget.Toast
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -16,9 +17,13 @@ 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.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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import retrofit2.HttpException
class NotificationsActivity : AppCompatActivity() { class NotificationsActivity : AppCompatActivity() {
private lateinit var rvNotifications: RecyclerView private lateinit var rvNotifications: RecyclerView
@ -26,6 +31,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 val users: MutableMap<String, UserResponse?> = mutableMapOf() // user id -> user (or null if not found)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -41,32 +47,33 @@ class NotificationsActivity : AppCompatActivity() {
rvNotifications = findViewById(R.id.rvNotifications) rvNotifications = findViewById(R.id.rvNotifications)
btnBack = findViewById(R.id.btnBack) btnBack = findViewById(R.id.btnBack)
rvNotifications.layoutManager = LinearLayoutManager(this) btnBack.setOnClickListener { finish() }
rvNotifications.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch { lifecycleScope.launch {
notifications = fetchNotifications().toMutableList() notifications = fetchNotifications().toMutableList()
invitations = fetchInvitations().toMutableMap() invitations = fetchInvitations().toMutableMap()
rvNotifications.adapter = NotificationAdapter(notifications, ::handleNotificationAction, ::handleNotificationClick) {
notification -> invitations[notification.data]
}
}
btnBack.setOnClickListener { finish() } rvNotifications.adapter = NotificationAdapter(
notifications,
::handleNotificationAction,
::handleNotificationClick,
::getInvitationData,
::getUserData
)
}
} }
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) {
finish() finish()
return emptyList() return emptyList()
} }
val notifications = withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
RetrofitClient.notificationsService.getUserNotifications(userId) RetrofitClient.notificationsService.getUserNotifications(userId)
} }
return notifications
} }
private suspend fun fetchInvitations(): Map<String, InvitationResponse> { private suspend fun fetchInvitations(): Map<String, InvitationResponse> {
@ -82,16 +89,55 @@ class NotificationsActivity : AppCompatActivity() {
return fetchedInvitations.associateBy { it.id } return fetchedInvitations.associateBy { it.id }
} }
private fun getInvitationData(invitationId: String, rvPosition: Int): InvitationResponse? {
return invitations[invitationId]
}
private fun getUserData(userId: String, rvPosition: Int?): UserResponse? {
return users.getOrPut(userId) {
lifecycleScope.launch(Dispatchers.IO) {
try {
val user = RetrofitClient.usersService.getUser(userId)
users[userId] = user // Cache the user
// Update the adapter item once we fetched this value
// This will re-trigger the logic for obtaining the item re-calling
// this function, but this time, it will give back the cached value
if (rvPosition != null) {
withContext(Dispatchers.Main) {
rvNotifications.adapter!!.notifyItemChanged(rvPosition)
}
}
} catch (e: HttpException) {
if (e.code() != 404) { throw e }
val errorBody = e.response()?.errorBody()?.string()
if (errorBody == null) { 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)
users[userId] = null // Cache null for non-existing users
// No need for an adapter update here, null is already the default
}
}
null // Return null until the data is fetched
}
}
private fun handleNotificationAction(notification: NotificationResponse, action: NotificationAdapter.Action, position: Int) { private fun handleNotificationAction(notification: NotificationResponse, action: NotificationAdapter.Action, position: Int) {
when (action) { when (action) {
NotificationAdapter.Action.ACCEPT -> { NotificationAdapter.Action.ACCEPT -> {
//TODO("Handle accept action") // TODO: Handle accept action
} }
NotificationAdapter.Action.DECLINE -> { NotificationAdapter.Action.DECLINE -> {
//TODO("Handle decline action") // TODO: Handle decline action
} }
NotificationAdapter.Action.VIEW_EVENT -> { NotificationAdapter.Action.VIEW_EVENT -> {
//TODO("Handle viewing the event") // TODO: Handle viewing the event
} }
} }
} }

View file

@ -9,6 +9,7 @@ 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.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 java.time.Duration import java.time.Duration
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -17,7 +18,8 @@ class NotificationAdapter(
private val notifications: MutableList<NotificationResponse>, private val notifications: MutableList<NotificationResponse>,
private val onActionClick: (NotificationResponse, Action, Int) -> Unit, private val onActionClick: (NotificationResponse, Action, Int) -> Unit,
private val onNotificationClick: (NotificationResponse, Int) -> Unit, private val onNotificationClick: (NotificationResponse, Int) -> Unit,
private val getInvitationData: (NotificationResponse) -> InvitationResponse?, private val getInvitationData: (String, Int) -> InvitationResponse?,
private val getUserData: (String, Int) -> UserResponse?,
) : RecyclerView.Adapter<NotificationAdapter.NotificationViewHolder>() { ) : RecyclerView.Adapter<NotificationAdapter.NotificationViewHolder>() {
enum class Action { enum class Action {
@ -44,28 +46,69 @@ class NotificationAdapter(
val notification = notifications[position] val notification = notifications[position]
// Format and set the creation time // Format and set the creation time
val formattedTime = formatNotificationTime(notification.created_at) holder.notificationTime.text = formatNotificationTime(notification.created_at)
holder.notificationTime.text = formattedTime
// Set visibility based on read/unread status // Set visibility based on read/unread status
holder.unreadIndicator.visibility = if (notification.read) View.GONE else View.VISIBLE holder.unreadIndicator.visibility = if (notification.read) View.GONE else View.VISIBLE
// Handle invitation actions // Handle notification message and invitation-specific logic
if (notification.event_type == "invitation") { holder.message.text = resolveNotificationMessage(notification, position)
val invitation = getInvitationData(notification)
// TODO: Consider fetching the user names here to show // Handle invitation actions
// TODO: Localize handleInvitationActions(holder, notification, position)
if (invitation != null) {
if (notification.message == "new-invitation") { // Set click listener for the notification (only for unread ones)
holder.message.setText("You have received an event invitation") holder.itemView.isClickable = !notification.read
} else if (notification.message == "invitation-accepted") { holder.itemView.setOnClickListener {
holder.message.setText("Your event invitation has been accepted") if (!notification.read) onNotificationClick(notification, position)
} else if (notification.message == "invitation-declined") { }
holder.message.setText("Your event invitation has been declined")
} }
override fun getItemCount(): Int = notifications.size
private fun resolveNotificationMessage(notification: NotificationResponse, position: Int): String {
if (notification.event_type != "invitation") {
return notification.message
}
val invitation = getInvitationData(notification.data, position)
?: return when (notification.message) {
"new-invitation" -> "You have received an event invitation, but it was since deleted"
"invitation-accepted" -> "Your event invitation has been accepted, but it was since deleted"
"invitation-declined" -> "Your event invitation has been declined, but it was since deleted"
else -> throw IllegalArgumentException("Unexpected notification message: ${notification.message}")
}
val usernameId = when (notification.message) {
"new-invitation" -> invitation.invitor_id
"invitation-accepted", "invitation-declined" -> invitation.invitee_id
else -> throw IllegalArgumentException("Unexpected notification message: ${notification.message}")
}
val user = getUserData(usernameId, position)
val username = user?.username ?: "Unknown User"
return when (notification.message) {
"new-invitation" -> "You have received an event invitation from @$username"
"invitation-accepted" -> "@$username has accepted your event invitation"
"invitation-declined" -> "@$username has declined your event invitation"
else -> throw IllegalArgumentException("Unexpected notification message: ${notification.message}")
}
}
private fun handleInvitationActions(
holder: NotificationViewHolder,
notification: NotificationResponse,
position: Int
) {
if (notification.event_type == "invitation") {
when (notification.message) {
"new-invitation" -> {
// Show Accept/Decline & View buttons
holder.invitationActions.visibility = View.VISIBLE holder.invitationActions.visibility = View.VISIBLE
holder.acceptButton.visibility = View.VISIBLE
holder.declineButton.visibility = View.VISIBLE
holder.viewEventButton.visibility = View.VISIBLE
holder.acceptButton.setOnClickListener { holder.acceptButton.setOnClickListener {
onActionClick(notification, Action.ACCEPT, position) onActionClick(notification, Action.ACCEPT, position)
@ -73,52 +116,27 @@ class NotificationAdapter(
holder.declineButton.setOnClickListener { holder.declineButton.setOnClickListener {
onActionClick(notification, Action.DECLINE, position) onActionClick(notification, Action.DECLINE, position)
} }
}
"invitation-accepted", "invitation-declined" -> {
// 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 { holder.viewEventButton.setOnClickListener {
onActionClick(notification, Action.VIEW_EVENT, position) onActionClick(notification, Action.VIEW_EVENT, position)
} }
} else {
if (notification.message == "new-invitation") {
holder.message.setText("You have received an event invitation [invitation deleted]")
} else if (notification.message == "invitation-accepted") {
holder.message.setText("Your event invitation has been accepted [invitation deleted]")
} else if (notification.message == "invitation-declined") {
holder.message.setText("Your event invitation has been declined [invitation deleted]")
} }
else -> {
throw IllegalArgumentException("Unexpected notification message: ${notification.message}")
}
}
} else {
holder.invitationActions.visibility = View.GONE holder.invitationActions.visibility = View.GONE
} }
} else {
holder.message.text = notification.message
holder.invitationActions.visibility = View.GONE
} }
// Set click listener for the whole notification (only for unread ones though)
if (!notification.read) {
holder.itemView.isClickable = true
holder.itemView.setOnClickListener {
onNotificationClick(notification, position)
}
} else {
holder.itemView.isClickable = false
holder.itemView.setOnClickListener(null)
}
// Ensure buttons consume their click events
holder.acceptButton.isClickable = true
holder.declineButton.isClickable = true
}
override fun getItemCount(): Int = notifications.size
/**
* Format the time at which a notification was received.
*
* If the notification is recent (setn within the last 24h), show the time
* in a format of (x hours/minutes/seconds ago), otherwise, use yyyy-MM-dd HH:mm
*/
private fun formatNotificationTime(createdAt: OffsetDateTime): String { private fun formatNotificationTime(createdAt: OffsetDateTime): String {
val now = OffsetDateTime.now() val now = OffsetDateTime.now()
val duration = Duration.between(createdAt, now) val duration = Duration.between(createdAt, now)
@ -127,11 +145,7 @@ class NotificationAdapter(
duration.seconds < 60 -> "${duration.seconds}s ago" duration.seconds < 60 -> "${duration.seconds}s ago"
duration.toMinutes() < 60 -> "${duration.toMinutes()}m ago" duration.toMinutes() < 60 -> "${duration.toMinutes()}m ago"
duration.toHours() < 24 -> "${duration.toHours()}h ago" duration.toHours() < 24 -> "${duration.toHours()}h ago"
else -> { else -> DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").format(createdAt)
// Format as a date/time for older notifications
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
createdAt.format(formatter)
}
} }
} }
} }