Compare commits

..

No commits in common. "046da599b7a614501fa9d0c136769a211178ae98" and "6781410b87f6ea6f1074d0a7faf326e0dba2b727" have entirely different histories.

13 changed files with 14 additions and 451 deletions

View file

@ -1,111 +1,21 @@
package com.p_vacho.neat_calendar.activities package com.p_vacho.neat_calendar.activities
import android.os.Bundle import android.os.Bundle
import android.widget.ImageButton
import android.widget.Toast
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.InvitationResponse
import com.p_vacho.neat_calendar.api.models.NotificationResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NotificationsActivity : AppCompatActivity() { class NotificationsActivity : AppCompatActivity() {
private lateinit var rvNotifications: RecyclerView
private lateinit var btnBack: ImageButton
private lateinit var notifications: MutableList<NotificationResponse>
private lateinit var invitations: MutableMap<String, InvitationResponse> // invitation id -> invitation
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContentView(R.layout.activity_notifications) setContentView(R.layout.activity_notification)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets insets
} }
rvNotifications = findViewById(R.id.rvNotifications)
btnBack = findViewById(R.id.btnBack)
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() }
} }
}
private suspend fun fetchNotifications(): List<NotificationResponse> {
val userId = (application as MyApplication).tokenManager.userId
if (userId == null) {
finish()
return emptyList()
}
val notifications = withContext(Dispatchers.IO) {
RetrofitClient.notificationsService.getUserNotifications(userId)
}
return notifications
}
private suspend fun fetchInvitations(): Map<String, InvitationResponse> {
val userId = (application as MyApplication).tokenManager.userId
if (userId == null) {
finish()
return emptyMap()
}
val fetchedInvitations = withContext(Dispatchers.IO) {
RetrofitClient.invitationService.getIncomingInvitations(userId)
}
return fetchedInvitations.associateBy { it.id }
}
private fun handleNotificationAction(notification: NotificationResponse, action: NotificationAdapter.Action, position: Int) {
when (action) {
NotificationAdapter.Action.ACCEPT -> {
//TODO("Handle accept action")
}
NotificationAdapter.Action.DECLINE -> {
//TODO("Handle decline action")
}
NotificationAdapter.Action.VIEW_EVENT -> {
//TODO("Handle viewing the event")
}
}
}
private fun handleNotificationClick(notification: NotificationResponse, position: Int) {
lifecycleScope.launch {
val updatedNotification =
RetrofitClient.notificationsService.markNotificationRead(notification.id)
notifications[position] = updatedNotification
val adapter = rvNotifications.adapter as NotificationAdapter
adapter.notifyItemChanged(position)
Toast.makeText(this@NotificationsActivity, "Marked as read", Toast.LENGTH_SHORT).show()
}
}
}

View file

@ -1,137 +0,0 @@
package com.p_vacho.neat_calendar.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.InvitationResponse
import com.p_vacho.neat_calendar.api.models.NotificationResponse
import java.time.Duration
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
class NotificationAdapter(
private val notifications: MutableList<NotificationResponse>,
private val onActionClick: (NotificationResponse, Action, Int) -> Unit,
private val onNotificationClick: (NotificationResponse, Int) -> Unit,
private val getInvitationData: (NotificationResponse) -> InvitationResponse?,
) : RecyclerView.Adapter<NotificationAdapter.NotificationViewHolder>() {
enum class Action {
ACCEPT, DECLINE, VIEW_EVENT
}
inner class NotificationViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val message: TextView = view.findViewById(R.id.notificationMessage)
val notificationTime: TextView = view.findViewById(R.id.notificationTime)
val acceptButton: ImageButton = view.findViewById(R.id.acceptButton)
val declineButton: ImageButton = view.findViewById(R.id.declineButton)
val viewEventButton: ImageButton = view.findViewById(R.id.viewEventButton)
val invitationActions: View = view.findViewById(R.id.invitationActions)
val unreadIndicator: View = view.findViewById(R.id.notificationIndicator)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_notification, parent, false)
return NotificationViewHolder(view)
}
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) {
val notification = notifications[position]
// Format and set the creation time
val formattedTime = formatNotificationTime(notification.created_at)
holder.notificationTime.text = formattedTime
// Set visibility based on read/unread status
holder.unreadIndicator.visibility = if (notification.read) View.GONE else View.VISIBLE
// Handle invitation actions
if (notification.event_type == "invitation") {
val invitation = getInvitationData(notification)
// 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 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 {
val now = OffsetDateTime.now()
val duration = Duration.between(createdAt, now)
return when {
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)
}
}
}
}

View file

@ -1,18 +1,6 @@
package com.p_vacho.neat_calendar.api.models package com.p_vacho.neat_calendar.api.models
import java.time.OffsetDateTime
data class InvitationRequest( data class InvitationRequest(
val event_id: String, val event_id: String,
val invitee_id: String, val invitee_id: String,
)
data class InvitationResponse(
val id: String,
val invitor_id: String,
val invitee_id: String,
val event_id: String,
val status: String, // "accepted" / "declined" / "pending"
val sent_at: OffsetDateTime,
val responded_at: OffsetDateTime?,
) )

View file

@ -5,7 +5,7 @@ import java.time.OffsetDateTime
data class NotificationResponse( data class NotificationResponse(
val id: String, val id: String,
val user_id: String, val user_id: String,
val event_type: String, // "message" / "invitation" val event_type: String, // "reminder" / "invitation"
val message: String, val message: String,
val data: String, val data: String,
val read: Boolean, val read: Boolean,

View file

@ -1,29 +1,10 @@
package com.p_vacho.neat_calendar.api.services package com.p_vacho.neat_calendar.api.services
import com.p_vacho.neat_calendar.api.models.InvitationRequest import com.p_vacho.neat_calendar.api.models.InvitationRequest
import com.p_vacho.neat_calendar.api.models.InvitationResponse
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path
interface InvitationsService { interface InvitationsService {
@POST("invitations") @POST("invitations")
suspend fun createInvitation(@Body invitationData: InvitationRequest): InvitationResponse suspend fun createInvitation(@Body invitationData: InvitationRequest)
@GET("invitations/{invitation_id}")
suspend fun getInvitation(@Path("invitation_id") invitiationId: String): InvitationResponse
@GET("users/{user_id}/invitations")
suspend fun getInvitations(@Path("user_id") userId: String): List<InvitationResponse>
@GET("users/{user_id}/invitations/incoming")
suspend fun getIncomingInvitations(@Path("user_id") userId: String): List<InvitationResponse>
@POST("invitations/{invitation_id}/accept")
suspend fun acceptInvitation(@Path("invitation_id") invitationId: String): Unit
@POST("invitations/{invitation_id}/decline")
suspend fun declineInvitation(@Path("invitation_id") invitationId: String): Unit
} }

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?attr/colorControlHighlight">
<item>
<shape android:shape="rectangle">
<solid android:color="?attr/colorSurface" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M382,720 L154,492l57,-57 171,171 367,-367 57,57 -424,424Z"
android:fillColor="#e8eaed"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M480,640q75,0 127.5,-52.5T660,460q0,-75 -52.5,-127.5T480,280q-75,0 -127.5,52.5T300,460q0,75 52.5,127.5T480,640ZM480,568q-45,0 -76.5,-31.5T372,460q0,-45 31.5,-76.5T480,352q45,0 76.5,31.5T588,460q0,45 -31.5,76.5T480,568ZM480,760q-146,0 -266,-81.5T40,460q54,-137 174,-218.5T480,160q146,0 266,81.5T920,460q-54,137 -174,218.5T480,760ZM480,460ZM480,680q113,0 207.5,-59.5T832,460q-50,-101 -144.5,-160.5T480,240q-113,0 -207.5,59.5T128,460q50,101 144.5,160.5T480,680Z"
android:fillColor="#e8eaed"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.NotificationsActivity">
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.NotificationsActivity">
<!-- Title Bar -->
<LinearLayout
android:id="@+id/titleBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="?android:attr/dividerHorizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<!-- Back Button -->
<ImageButton
android:id="@+id/btnBack"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_arrow_back"
android:contentDescription="@string/back"
app:tint="?android:attr/textColorPrimary" />
<!-- Title -->
<TextView
android:id="@+id/tvTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="18sp"
android:textStyle="bold"
android:gravity="center"
android:text="@string/notifications" />
</LinearLayout>
<!-- RecyclerView for displaying notifications -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvNotifications"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/titleBar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/item_notification"
tools:itemCount="5"
android:clipToPadding="false"
android:padding="16dp"
android:paddingBottom="24dp"
android:scrollbars="vertical"
android:layout_marginBottom="16dp"
android:layout_marginTop="8dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,91 +0,0 @@
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:clickable="true"
android:padding="16dp"
android:elevation="16dp"
android:background="@drawable/bg_notification_item_ripple">
<!-- Read/Unread Indicator as vertical line -->
<View
android:id="@+id/notificationIndicator"
android:layout_width="4dp"
android:layout_height="0dp"
android:layout_marginEnd="8dp"
android:background="@color/unreadIndicator"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Message Text -->
<TextView
android:id="@+id/notificationMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Sample notification message"
android:textSize="16sp"
android:ellipsize="end"
android:maxLines="2"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/notificationIndicator"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginLeft="12dp" />
<!-- Time Received -->
<TextView
android:id="@+id/notificationTime"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="2h ago"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
app:layout_constraintTop_toBottomOf="@id/notificationMessage"
app:layout_constraintStart_toStartOf="@id/notificationMessage" />
<!-- Buttons for invitations -->
<LinearLayout
android:id="@+id/invitationActions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:visibility="gone"
tools:visibility="visible"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/notificationTime"
app:layout_constraintEnd_toEndOf="parent">
<ImageButton
android:id="@+id/acceptButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_check"
android:contentDescription="@string/accept"
app:tint="@android:color/holo_green_dark" />
<ImageButton
android:id="@+id/declineButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_close"
android:contentDescription="@string/decline"
app:tint="@android:color/holo_red_dark" />
<ImageButton
android:id="@+id/viewEventButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_visibility"
android:contentDescription="@string/view_event"
app:tint="@android:color/holo_blue_dark" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -7,5 +7,4 @@
<color name="splash_dark_background">#121212</color> <!-- Dark gray --> <color name="splash_dark_background">#121212</color> <!-- Dark gray -->
<color name="event_indicator_color">#0035D0</color> <color name="event_indicator_color">#0035D0</color>
<color name="unreadIndicator">#bb6633</color>
</resources> </resources>

View file

@ -75,8 +75,4 @@
<string name="choose_event_color">Choose Event Color</string> <string name="choose_event_color">Choose Event Color</string>
<string name="select_time">Select Time</string> <string name="select_time">Select Time</string>
<string name="manage_categories">Manage categories</string> <string name="manage_categories">Manage categories</string>
<string name="accept">Accept</string>
<string name="decline">Decline</string>
<string name="notifications">Notifications</string>
<string name="view_event">View Event</string>
</resources> </resources>