chore(mvvm): CategoryActivity ViewModel

This commit is contained in:
Peter Vacho 2025-01-22 21:19:51 +01:00
parent fa2bb2c78a
commit 6832d25163
Signed by: school
GPG key ID: 8CFC3837052871B4
3 changed files with 165 additions and 85 deletions

View file

@ -7,30 +7,28 @@ import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
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.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.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.CategoryResponse
import com.p_vacho.neat_calendar.viewmodels.CategoriesViewModel
import com.p_vacho.neat_calendar.viewmodels.CategoryEvent
import com.p_vacho.neat_calendar.viewmodels.CategoryMode import com.p_vacho.neat_calendar.viewmodels.CategoryMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CategoriesActivity : AppCompatActivity() { class CategoriesActivity : AppCompatActivity() {
private val viewModel: CategoriesViewModel by viewModels()
private lateinit var adapter: CategoryAdapter
private lateinit var rvCategories: RecyclerView private lateinit var rvCategories: RecyclerView
private lateinit var btnBack: ImageButton private lateinit var btnBack: ImageButton
private lateinit var btnAddCategory: ImageButton private lateinit var btnAddCategory: ImageButton
private lateinit var tvEmptyState: TextView private lateinit var tvEmptyState: TextView
private lateinit var categories: MutableList<CategoryResponse>
private val createActivityLauncher = registerForActivityResult( private val createActivityLauncher = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult() androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
@ -42,8 +40,11 @@ class CategoriesActivity : AppCompatActivity() {
val editedCategory: CategoryResponse? = result.data?.getParcelableExtra("editedCategory") val editedCategory: CategoryResponse? = result.data?.getParcelableExtra("editedCategory")
if (newCategory != null && editedCategory != null) throw IllegalStateException("Got both edit & new response") if (newCategory != null && editedCategory != null) throw IllegalStateException("Got both edit & new response")
if (newCategory != null) categoryCreateReply(newCategory, CategoryMode.CREATE)
if (editedCategory != null) categoryCreateReply(editedCategory, CategoryMode.EDIT) when {
newCategory != null -> viewModel.handleCategoryAddOrEdit(newCategory, CategoryMode.CREATE)
editedCategory != null -> viewModel.handleCategoryAddOrEdit(editedCategory, CategoryMode.EDIT)
}
} }
} }
@ -58,55 +59,47 @@ class CategoriesActivity : AppCompatActivity() {
insets insets
} }
initializeViews()
// Observers
viewModel.categoryEvents.observe(this) { event ->
when (event) {
is CategoryEvent.Init -> adapter.setCategories(event.categories)
is CategoryEvent.Add -> adapter.addCategory(event.category)
is CategoryEvent.Edit -> adapter.editCategory(event.category)
is CategoryEvent.Remove -> {
adapter.removeCategory(event.category)
// TODO: Localize
Toast.makeText(this, "Category deleted", Toast.LENGTH_SHORT).show()
}
}
}
viewModel.isEmptyStateVisible.observe(this) { isVisible ->
tvEmptyState.visibility = if (isVisible) View.VISIBLE else View.GONE
}
// Listeners
btnBack.setOnClickListener { finish() }
btnAddCategory.setOnClickListener { handleCreateCategory() }
// Load initial categories
viewModel.fetchCategories()
}
private fun initializeViews() {
rvCategories = findViewById(R.id.rvCategories) rvCategories = findViewById(R.id.rvCategories)
btnBack = findViewById(R.id.btnBack) btnBack = findViewById(R.id.btnBack)
btnAddCategory = findViewById(R.id.btnAddCategory) btnAddCategory = findViewById(R.id.btnAddCategory)
tvEmptyState = findViewById(R.id.tvEmptyState) tvEmptyState = findViewById(R.id.tvEmptyState)
btnBack.setOnClickListener { finish() } adapter = CategoryAdapter(
btnAddCategory.setOnClickListener { handleCreateCategory() } mutableListOf(),
{ category, _pos -> viewModel.deleteCategory(category.id) },
::handleEditCategory
)
rvCategories.layoutManager = LinearLayoutManager(this) rvCategories.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch {
categories = fetchCategories().toMutableList()
val adapter = CategoryAdapter(categories, ::handleDeleteCategory, ::handleEditCategory)
rvCategories.adapter = adapter 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()
}
} }
/** /**
@ -133,30 +126,4 @@ class CategoriesActivity : AppCompatActivity() {
} }
createActivityLauncher.launch(intent) createActivityLauncher.launch(intent)
} }
/**
* Used as a callback, triggered when the CreateCategory Activity returns a result.
*
* The returned value (the new / edited category data) is passed over as a parameter.
*/
private fun categoryCreateReply(category: CategoryResponse, mode: CategoryMode) {
val adapter = (rvCategories.adapter as CategoryAdapter)
when (mode) {
CategoryMode.CREATE -> {
adapter.addCategory(category)
updateEmptyState()
}
CategoryMode.EDIT -> { adapter.editCategory(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

@ -1,5 +1,6 @@
package com.p_vacho.neat_calendar.adapters package com.p_vacho.neat_calendar.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -43,11 +44,10 @@ class CategoryAdapter(
override fun getItemCount(): Int = categories.size override fun getItemCount(): Int = categories.size
/** /**
* Remove a category from the list and notify the adapter. * Remove an existing 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) { fun removeCategory(category: CategoryResponse) {
val position = categories.indexOfFirst { it.id == category.id }
categories.removeAt(position) categories.removeAt(position)
notifyItemRemoved(position) notifyItemRemoved(position)
@ -68,12 +68,24 @@ class CategoryAdapter(
/** /**
* Edit an existing category, updating it in the UI. * Edit an existing category, updating it in the UI.
*
* Call this after the onEditClick callback edits the category from the backend API.
*/ */
fun editCategory(category: CategoryResponse) { fun editCategory(category: CategoryResponse) {
val position = categories.indexOfFirst { it.id == category.id } val position = categories.indexOfFirst { it.id == category.id }
categories[position] = category categories[position] = category
notifyItemChanged(position) notifyItemChanged(position)
} }
/**
* Reset the entire list of categories to a new one.
*
* This is mainly useful for delayed initialization (after data is fetched).
*/
fun setCategories(newCategories: List<CategoryResponse>) {
categories.clear()
categories.addAll(newCategories)
// We can't be more efficient here, we're changing the whole data set
@Suppress("NotifyDataSetChanged")
notifyDataSetChanged()
}
} }

View file

@ -0,0 +1,101 @@
package com.p_vacho.neat_calendar.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.p_vacho.neat_calendar.MyApplication
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.CategoryResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
sealed class CategoryEvent {
data class Init(val categories: List<CategoryResponse>) : CategoryEvent()
data class Add(val category: CategoryResponse) : CategoryEvent()
data class Edit(val position: Int, val category: CategoryResponse) : CategoryEvent()
data class Remove(val position: Int, val category: CategoryResponse) : CategoryEvent()
}
class CategoriesViewModel(application: Application) : AndroidViewModel(application) {
private val categories = mutableListOf<CategoryResponse>()
private val _isEmptyStateVisible = MutableLiveData<Boolean>()
val isEmptyStateVisible: LiveData<Boolean> = _isEmptyStateVisible
private val _categoryEvents = MutableLiveData<CategoryEvent>()
val categoryEvents: LiveData<CategoryEvent> = _categoryEvents
init {
// Initialize empty state visibility to true as the default
updateEmptyStateVisibility()
}
/**
* Fetches all categories for the current user from the backend.
*
* This function should always be called after initialization.
*/
fun fetchCategories() {
viewModelScope.launch {
val userId = (getApplication<MyApplication>()).tokenManager.userId ?: return@launch
val fetchedCategories = withContext(Dispatchers.IO) {
RetrofitClient.categoryService.userCategories(userId)
}
categories.clear()
categories.addAll(fetchedCategories)
_categoryEvents.value = CategoryEvent.Init(fetchedCategories)
updateEmptyStateVisibility()
}
}
/**
* Delete a specific category.
*
* This will perform an API call, removing the category from the backend.
*/
fun deleteCategory(categoryId: String) {
val index = categories.indexOfFirst { it.id == categoryId }
if (index == -1) throw IllegalStateException("Attempted to delete an unknown category; id: $categoryId")
viewModelScope.launch {
withContext(Dispatchers.IO) {
RetrofitClient.categoryService.deleteCategory(categoryId)
}
val removedCategory = categories.removeAt(index)
_categoryEvents.value = CategoryEvent.Remove(index, removedCategory)
updateEmptyStateVisibility()
}
}
/**
* Update the categories list with a new/updated category data.
*/
fun handleCategoryAddOrEdit(category: CategoryResponse, mode: CategoryMode) {
when (mode) {
CategoryMode.CREATE -> {
categories.add(category)
_categoryEvents.value = CategoryEvent.Add(category)
updateEmptyStateVisibility()
}
CategoryMode.EDIT -> {
val index = categories.indexOfFirst { it.id == category.id }
if (index != -1) {
categories[index] = category
_categoryEvents.value = CategoryEvent.Edit(index, category)
}
}
}
}
/**
* Updates the empty state visibility based on the categories list.
*/
private fun updateEmptyStateVisibility() {
_isEmptyStateVisible.value = categories.isEmpty()
}
}