Compare commits

...

3 commits

11 changed files with 434 additions and 13 deletions

View file

@ -16,6 +16,9 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.NeatCalendar" android:theme="@style/Theme.NeatCalendar"
tools:targetApi="31"> tools:targetApi="31">
<activity
android:name=".activities.CreateCategoryActivity"
android:exported="false" />
<activity <activity
android:name=".activities.CategoriesActivity" android:name=".activities.CategoriesActivity"
android:exported="false" /> android:exported="false" />

View file

@ -1,21 +1,133 @@
package com.p_vacho.neat_calendar.activities package com.p_vacho.neat_calendar.activities
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.ImageButton
import android.widget.TextView
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.CategoryAdapter
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.CategoryResponse
import com.p_vacho.neat_calendar.api.models.EventResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CategoriesActivity : AppCompatActivity() { class CategoriesActivity : AppCompatActivity() {
private lateinit var rvCategories: RecyclerView
private lateinit var btnBack: ImageButton
private lateinit var btnAddCategory: ImageButton
private lateinit var tvEmptyState: TextView
private lateinit var categories: MutableList<CategoryResponse>
private val createActivityLauncher = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
@Suppress("DEPRECATION")
val newCategory: CategoryResponse? = result.data?.getParcelableExtra("newCategory")
newCategory?.let { categoryCreateReply(it) }
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContentView(R.layout.activity_categories) setContentView(R.layout.activity_categories)
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
} }
rvCategories = findViewById(R.id.rvCategories)
btnBack = findViewById(R.id.btnBack)
btnAddCategory = findViewById(R.id.btnAddCategory)
tvEmptyState = findViewById(R.id.tvEmptyState)
btnBack.setOnClickListener { finish() }
btnAddCategory.setOnClickListener { navigateToCreateCategory() }
rvCategories.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch {
categories = fetchCategories().toMutableList()
val adapter = CategoryAdapter(categories, ::handleDeleteCategory)
rvCategories.adapter = adapter
updateEmptyState()
}
} }
}
/**
* Fetches all categories for the current user from the backend.
*/
private suspend fun fetchCategories(): List<CategoryResponse> {
val userId = (application as MyApplication).tokenManager.userId
?: run {
finish()
return emptyList()
}
return withContext(Dispatchers.IO) {
RetrofitClient.categoryService.userCategories(userId)
}
}
/**
* A callback function triggered by the Category Adapter, when the user
* clicks on the delete button.
*/
private fun handleDeleteCategory(category: CategoryResponse, position: Int) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
RetrofitClient.categoryService.deleteCategory(category.id)
}
// Remove category from the adapter and update the UI
(rvCategories.adapter as CategoryAdapter).removeCategoryAt(position)
Toast.makeText(this@CategoriesActivity, "Category deleted", Toast.LENGTH_SHORT).show()
updateEmptyState()
}
}
/**
* Navigates to the activity for adding a new category.
*/
private fun navigateToCreateCategory() {
val intent = Intent(this, CreateCategoryActivity::class.java)
startActivity(intent)
}
/**
* Used as a callback, triggered when the CreateCategory Activity returns a result.
*
* The returned value (the new category data) is passed over as a parameter.
*/
private fun categoryCreateReply(category: CategoryResponse) {
(rvCategories.adapter as CategoryAdapter).addCategory(category)
}
/**
* This is a helper function to toggle the visibility of the empty state message
* when no categories are available.
*
* This should be called whenever a category was removed or added.
*/
private fun updateEmptyState() {
tvEmptyState.visibility = if (categories.isEmpty()) View.VISIBLE else View.GONE
}
}

View file

@ -0,0 +1,21 @@
package com.p_vacho.neat_calendar.activities
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.p_vacho.neat_calendar.R
class CreateCategoryActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_create_category)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}

View file

@ -127,6 +127,10 @@ class NotificationsActivity : AppCompatActivity() {
return fetchedEvents.associateBy { it.id } return fetchedEvents.associateBy { it.id }
} }
/**
* Fetch both the incoming & outgoing (owned) invitations for the currently
* logged in user.
*/
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) {
@ -146,6 +150,13 @@ class NotificationsActivity : AppCompatActivity() {
return fetchedInvitations.associateBy { it.id } return fetchedInvitations.associateBy { it.id }
} }
/**
* A callback function passed to the Notification Adapter, to allow it to request
* a specific invitation by ID.
*
* This function only obtains the invitation from the pre-fetched invitations list,
* it will not make any new requests. Unknown IDs will result in null.
*/
private fun getInvitationData(invitationId: String, rvPosition: Int): InvitationResponse? { private fun getInvitationData(invitationId: String, rvPosition: Int): InvitationResponse? {
val ret = invitations[invitationId] val ret = invitations[invitationId]
if (ret == null) { if (ret == null) {
@ -155,6 +166,13 @@ class NotificationsActivity : AppCompatActivity() {
return ret return ret
} }
/**
* A callback function passed to the Notification Adapter, to allow it to request
* a specific event by ID.
*
* This function only obtains the events from the pre-fetched events list,
* it will not make any new requests. Unknown IDs will result in null.
*/
private fun getEventData(eventId: String, rvPosition: Int): EventResponse? { private fun getEventData(eventId: String, rvPosition: Int): EventResponse? {
val ret = events[eventId] val ret = events[eventId]
if (ret == null) { if (ret == null) {
@ -164,6 +182,21 @@ class NotificationsActivity : AppCompatActivity() {
return ret return ret
} }
/**
* A callback function passed to the Notification Adapter, to allow it to request
* a specific user by ID.
*
* This function obtains the users lazily, making a new API request whenever it
* encounters an unknown ID. After obtaining the user data, it will be cached and
* another request for the same user will not be made.
*
* Since the API call needs to be async, instead of making this function block, it
* will immediately return null at first, while the leaving the coroutine for the
* API fetching runs in the background. Once this request finished, if successful,
* the adapter will be notified about a change of this item, which will make it
* call this function again. This time though, it will return immediately from cache,
* without any making further API calls.
*/
private fun getUserData(userId: String, rvPosition: Int?): UserResponse? { private fun getUserData(userId: String, rvPosition: Int?): UserResponse? {
return users.getOrPut(userId) { return users.getOrPut(userId) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@ -198,6 +231,15 @@ class NotificationsActivity : AppCompatActivity() {
} }
} }
/**
* A callback function passed to the Notification Adapter, triggered by clicking on one of the
* action buttons of the notification. (Currently, the only actions notifications support are
* invite related).
*
* The notification adapter should only call this function if the action is performable, so
* we shouldn't need to perform any additional checks for whether the requested action makes
* sense for given notification.
*/
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 -> {
@ -247,6 +289,12 @@ class NotificationsActivity : AppCompatActivity() {
} }
} }
/**
* A callback function passed to the Notification Adapter, triggered by clicking on the notification
* itself. This should mark the notification as read.
*
* The adapter should only call this function if the notification isn't already marked as read.
*/
private fun handleNotificationClick(notification: NotificationResponse, position: Int, sendToast: Boolean = true) { private fun handleNotificationClick(notification: NotificationResponse, position: Int, sendToast: Boolean = true) {
lifecycleScope.launch { lifecycleScope.launch {
val updatedNotification = val updatedNotification =
@ -262,6 +310,11 @@ class NotificationsActivity : AppCompatActivity() {
} }
} }
/**
* Attach an ItemTouchHelper to the recycler view adapter, to allow swiping
* of it's items. We only enable swiping to the right, which will trigger
* a deletion of that notification.
*/
private fun setupSwipeToDelete(adapter: NotificationAdapter) { private fun setupSwipeToDelete(adapter: NotificationAdapter) {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) { val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) {
override fun onMove( override fun onMove(
@ -280,15 +333,9 @@ class NotificationsActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
RetrofitClient.notificationsService.deleteNotification(notification.id) RetrofitClient.notificationsService.deleteNotification(notification.id)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// Remove the notification & notify the adapter about it // This both notifies the adapter & removes the notification from the
notifications.removeAt(position) // notifications list
adapter.notifyItemRemoved(position) (rvNotifications.adapter as NotificationAdapter).removeNotificationAt(position)
// Annoyingly, we can't just use notifyItemRemoved for the single removed item,
// as all the items below it would now be using the wrong position that was
// already bounded to the callbacks from the click listeners, so we need to refresh
// all of the notifications below this one as well.
adapter.notifyItemRangeChanged(position, notifications.size - position)
Toast.makeText(this@NotificationsActivity, "Notification deleted", Toast.LENGTH_SHORT).show() Toast.makeText(this@NotificationsActivity, "Notification deleted", Toast.LENGTH_SHORT).show()
updateEmptyState() updateEmptyState()
@ -301,6 +348,12 @@ class NotificationsActivity : AppCompatActivity() {
itemTouchHelper.attachToRecyclerView(rvNotifications) itemTouchHelper.attachToRecyclerView(rvNotifications)
} }
/**
* This is a helper function to toggle the visibility of the empty state message
* when no notifications are available.
*
* This should be called whenever a notification was removed or added.
*/
private fun updateEmptyState() { private fun updateEmptyState() {
val isEmpty = notifications.isEmpty() val isEmpty = notifications.isEmpty()
tvEmptyState.visibility = if (isEmpty) View.VISIBLE else View.GONE tvEmptyState.visibility = if (isEmpty) View.VISIBLE else View.GONE

View file

@ -0,0 +1,68 @@
package com.p_vacho.neat_calendar.adapters
import android.graphics.Color
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.CategoryResponse
class CategoryAdapter(
private val categories: MutableList<CategoryResponse>,
private val onDeleteClick: (CategoryResponse, Int) -> Unit
) : RecyclerView.Adapter<CategoryAdapter.CategoryViewHolder>() {
inner class CategoryViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val colorIndicator: View = view.findViewById(R.id.colorIndicator)
val categoryName: TextView = view.findViewById(R.id.tvCategoryName)
val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteCategory)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_category, parent, false)
return CategoryViewHolder(view)
}
override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) {
val category = categories[position]
// Bind category data to the views
holder.colorIndicator.setBackgroundColor(category.color.toArgb())
holder.categoryName.text = category.name
// Set click listener for the delete button
holder.deleteButton.setOnClickListener {
onDeleteClick(category, position)
}
}
override fun getItemCount(): Int = categories.size
/**
* Remove a category from the list and notify the adapter.
*
* Call this after the onDeleteClick callback deletes the category from the backend API.
*/
fun removeCategoryAt(position: Int) {
categories.removeAt(position)
notifyItemRemoved(position)
// Annoyingly, we can't just use notifyItemRemoved for the single removed item,
// as all the items below it would now be using the wrong position that was
// already bound to the callbacks from the click listeners, so we need to refresh
// all of the categories below this one as well.
notifyItemRangeChanged(position, categories.size - position)
}
/**
* Add a new category to the end of the list and notify the adapter.
*/
fun addCategory(category: CategoryResponse) {
categories.add(category)
notifyItemInserted(categories.size)
}
}

View file

@ -189,4 +189,20 @@ class NotificationAdapter(
else -> DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").format(createdAt) else -> DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").format(createdAt)
} }
} }
/**
* Remove a notification from the list and notify the adapter.
*
* Call this after the callback deletes the notification from the backend API.
*/
fun removeNotificationAt(position: Int) {
notifications.removeAt(position)
notifyItemRemoved(position)
// Annoyingly, we can't just use notifyItemRemoved for the single removed item,
// as all the items below it would now be using the wrong position that was
// already bound to the callbacks from the click listeners, so we need to refresh
// all of the notifications below this one as well.
notifyItemRangeChanged(position, notifications.size - position)
}
} }

View file

@ -1,6 +1,7 @@
package com.p_vacho.neat_calendar.api.services package com.p_vacho.neat_calendar.api.services
import com.p_vacho.neat_calendar.api.models.CategoryResponse import com.p_vacho.neat_calendar.api.models.CategoryResponse
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
@ -11,6 +12,9 @@ interface CategoryService {
@GET("/events/{event_id}/categories") @GET("/events/{event_id}/categories")
suspend fun eventCategories(@Path("event_id") eventId: String): List<CategoryResponse> suspend fun eventCategories(@Path("event_id") eventId: String): List<CategoryResponse>
@GET("/category/{category_id}") @GET("/categories/{category_id}")
suspend fun getCategory(@Path("category_id") categoryId: String): CategoryResponse suspend fun getCategory(@Path("category_id") categoryId: String): CategoryResponse
@DELETE("/categories/{category_id}")
suspend fun deleteCategory(@Path("category_id") categoryId: String): Unit
} }

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main" android:id="@+id/main"
@ -7,4 +8,84 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".activities.CategoriesActivity"> tools:context=".activities.CategoriesActivity">
</androidx.constraintlayout.widget.ConstraintLayout> <!-- 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/categories"
tools:text="Categories" />
<!-- Add Button -->
<ImageButton
android:id="@+id/btnAddCategory"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_add"
android:contentDescription="@string/add_category"
app:tint="?android:attr/textColorPrimary" />
</LinearLayout>
<!-- RecyclerView for displaying categories -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCategories"
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_category"
tools:itemCount="5"
android:clipToPadding="false"
android:padding="16dp"
android:paddingBottom="24dp"
android:scrollbars="vertical"
android:layout_marginBottom="16dp"
android:layout_marginTop="8dp" />
<!-- Empty State -->
<TextView
android:id="@+id/tvEmptyState"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/no_categories"
android:textSize="16sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/titleBar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

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.CreateCategoryActivity">
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,50 @@
<?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:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<!-- Color Indicator -->
<View
android:id="@+id/colorIndicator"
android:layout_width="8dp"
android:layout_height="match_parent"
android:background="@android:color/holo_blue_dark"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Category Name -->
<TextView
android:id="@+id/tvCategoryName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
tools:text="Work"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/colorIndicator"
app:layout_constraintEnd_toStartOf="@id/btnDeleteCategory" />
<!-- Delete Button -->
<ImageButton
android:id="@+id/btnDeleteCategory"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_trashbin"
android:contentDescription="@string/delete_category"
app:tint="?android:attr/textColorSecondary"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -81,4 +81,7 @@
<string name="view_event">View Event</string> <string name="view_event">View Event</string>
<string name="swipe_to_delete_hint">Swipe right on a notification to delete it.</string> <string name="swipe_to_delete_hint">Swipe right on a notification to delete it.</string>
<string name="no_notifications">You\'re all caught up! No notifications right now.</string> <string name="no_notifications">You\'re all caught up! No notifications right now.</string>
<string name="no_categories">No categories found</string>
<string name="categories">Categories</string>
<string name="delete_category">Delete category</string>
</resources> </resources>