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

@ -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 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">
<!-- 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> </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>