From 7a0f3cea06b72352343cd98473902fa1e5773789 Mon Sep 17 00:00:00 2001 From: Peter Vacho Date: Sat, 4 Jan 2025 14:13:50 +0100 Subject: [PATCH] feat(notifications): Fetch users & other necessary data --- .../activities/NotificationsActivity.kt | 72 ++++++-- .../adapters/NotificationAdapter.kt | 156 ++++++++++-------- 2 files changed, 144 insertions(+), 84 deletions(-) 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 f9bd00b..8826e32 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 @@ -1,6 +1,7 @@ package com.p_vacho.neat_calendar.activities import android.os.Bundle +import android.util.Log import android.widget.ImageButton import android.widget.Toast 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.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.launch import kotlinx.coroutines.withContext +import org.json.JSONException +import org.json.JSONObject +import retrofit2.HttpException class NotificationsActivity : AppCompatActivity() { private lateinit var rvNotifications: RecyclerView @@ -26,6 +31,7 @@ class NotificationsActivity : AppCompatActivity() { private lateinit var notifications: MutableList private lateinit var invitations: MutableMap // invitation id -> invitation + private val users: MutableMap = mutableMapOf() // user id -> user (or null if not found) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,32 +47,33 @@ class NotificationsActivity : AppCompatActivity() { rvNotifications = findViewById(R.id.rvNotifications) btnBack = findViewById(R.id.btnBack) - rvNotifications.layoutManager = LinearLayoutManager(this) + btnBack.setOnClickListener { finish() } + rvNotifications.layoutManager = LinearLayoutManager(this) lifecycleScope.launch { notifications = fetchNotifications().toMutableList() 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 { val userId = (application as MyApplication).tokenManager.userId - if (userId == null) { finish() return emptyList() } - val notifications = withContext(Dispatchers.IO) { + return withContext(Dispatchers.IO) { RetrofitClient.notificationsService.getUserNotifications(userId) } - - return notifications } private suspend fun fetchInvitations(): Map { @@ -82,16 +89,55 @@ class NotificationsActivity : AppCompatActivity() { 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) { when (action) { NotificationAdapter.Action.ACCEPT -> { - //TODO("Handle accept action") + // TODO: Handle accept action } NotificationAdapter.Action.DECLINE -> { - //TODO("Handle decline action") + // TODO: Handle decline action } NotificationAdapter.Action.VIEW_EVENT -> { - //TODO("Handle viewing the event") + // TODO: Handle viewing the event } } } 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 4acf486..b37046f 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 @@ -9,6 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import com.p_vacho.neat_calendar.R 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 java.time.Duration import java.time.OffsetDateTime import java.time.format.DateTimeFormatter @@ -17,8 +18,9 @@ class NotificationAdapter( private val notifications: MutableList, private val onActionClick: (NotificationResponse, Action, Int) -> Unit, private val onNotificationClick: (NotificationResponse, Int) -> Unit, - private val getInvitationData: (NotificationResponse) -> InvitationResponse?, -) : RecyclerView.Adapter() { + private val getInvitationData: (String, Int) -> InvitationResponse?, + private val getUserData: (String, Int) -> UserResponse?, + ) : RecyclerView.Adapter() { enum class Action { ACCEPT, DECLINE, VIEW_EVENT @@ -44,81 +46,97 @@ class NotificationAdapter( val notification = notifications[position] // Format and set the creation time - val formattedTime = formatNotificationTime(notification.created_at) - holder.notificationTime.text = formattedTime + holder.notificationTime.text = formatNotificationTime(notification.created_at) // Set visibility based on read/unread status holder.unreadIndicator.visibility = if (notification.read) View.GONE else View.VISIBLE + // Handle notification message and invitation-specific logic + holder.message.text = resolveNotificationMessage(notification, position) + // Handle invitation actions - if (notification.event_type == "invitation") { - val invitation = getInvitationData(notification) + handleInvitationActions(holder, notification, position) - // TODO: Consider fetching the user names here to show - // TODO: Localize - if (invitation != null) { - if (notification.message == "new-invitation") { - holder.message.setText("You have received an event invitation") - } else if (notification.message == "invitation-accepted") { - holder.message.setText("Your event invitation has been accepted") - } else if (notification.message == "invitation-declined") { - holder.message.setText("Your event invitation has been declined") - } - - holder.invitationActions.visibility = View.VISIBLE - - 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) - } - - } 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]") - } - - holder.invitationActions.visibility = View.GONE - } - - } else { - holder.message.text = notification.message - - holder.invitationActions.visibility = View.GONE + // Set click listener for the notification (only for unread ones) + holder.itemView.isClickable = !notification.read + holder.itemView.setOnClickListener { + if (!notification.read) onNotificationClick(notification, position) } - - // 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 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.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) + } + } + "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 { + onActionClick(notification, Action.VIEW_EVENT, position) + } + } + else -> { + throw IllegalArgumentException("Unexpected notification message: ${notification.message}") + } + } + } else { + holder.invitationActions.visibility = View.GONE + } + } + private fun formatNotificationTime(createdAt: OffsetDateTime): String { val now = OffsetDateTime.now() val duration = Duration.between(createdAt, now) @@ -127,11 +145,7 @@ class NotificationAdapter( duration.seconds < 60 -> "${duration.seconds}s ago" duration.toMinutes() < 60 -> "${duration.toMinutes()}m ago" duration.toHours() < 24 -> "${duration.toHours()}h ago" - else -> { - // Format as a date/time for older notifications - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - createdAt.format(formatter) - } + else -> DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").format(createdAt) } } }