feat(categories): Add activity for category management

This commit is contained in:
Peter Vacho 2025-01-04 22:23:35 +01:00
parent 5ba14f2aba
commit 354e00487a
Signed by: school
GPG key ID: 8CFC3837052871B4
9 changed files with 356 additions and 4 deletions

View file

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

View file

@ -1,21 +1,133 @@
package com.p_vacho.neat_calendar.activities
import android.content.Intent
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.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
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.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() {
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?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_categories)
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
}
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

@ -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

@ -1,6 +1,7 @@
package com.p_vacho.neat_calendar.api.services
import com.p_vacho.neat_calendar.api.models.CategoryResponse
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Path
@ -11,6 +12,9 @@ interface CategoryService {
@GET("/events/{event_id}/categories")
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
@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"?>
<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:tools="http://schemas.android.com/tools"
android:id="@+id/main"
@ -7,4 +8,84 @@
android:layout_height="match_parent"
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="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_categories">No categories found</string>
<string name="categories">Categories</string>
<string name="delete_category">Delete category</string>
</resources>