From 6832d25163627e1472a1e19fc9ac093a4191f5c3 Mon Sep 17 00:00:00 2001 From: Peter Vacho Date: Wed, 22 Jan 2025 21:19:51 +0100 Subject: [PATCH] chore(mvvm): CategoryActivity ViewModel --- .../activities/CategoriesActivity.kt | 125 +++++++----------- .../neat_calendar/adapters/CategoryAdapter.kt | 24 +++- .../viewmodels/CategoriesViewModel.kt | 101 ++++++++++++++ 3 files changed, 165 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CategoriesViewModel.kt diff --git a/app/src/main/java/com/p_vacho/neat_calendar/activities/CategoriesActivity.kt b/app/src/main/java/com/p_vacho/neat_calendar/activities/CategoriesActivity.kt index 2dba975..fb7c392 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/activities/CategoriesActivity.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/activities/CategoriesActivity.kt @@ -7,30 +7,28 @@ import android.widget.ImageButton import android.widget.TextView import android.widget.Toast import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels 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.viewmodels.CategoriesViewModel +import com.p_vacho.neat_calendar.viewmodels.CategoryEvent import com.p_vacho.neat_calendar.viewmodels.CategoryMode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class CategoriesActivity : AppCompatActivity() { + private val viewModel: CategoriesViewModel by viewModels() + private lateinit var adapter: CategoryAdapter + 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 - private val createActivityLauncher = registerForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult() ) { result -> @@ -42,8 +40,11 @@ class CategoriesActivity : AppCompatActivity() { val editedCategory: CategoryResponse? = result.data?.getParcelableExtra("editedCategory") 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 } + 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) btnBack = findViewById(R.id.btnBack) btnAddCategory = findViewById(R.id.btnAddCategory) tvEmptyState = findViewById(R.id.tvEmptyState) - btnBack.setOnClickListener { finish() } - btnAddCategory.setOnClickListener { handleCreateCategory() } - + adapter = CategoryAdapter( + mutableListOf(), + { category, _pos -> viewModel.deleteCategory(category.id) }, + ::handleEditCategory + ) rvCategories.layoutManager = LinearLayoutManager(this) - - lifecycleScope.launch { - categories = fetchCategories().toMutableList() - - val adapter = CategoryAdapter(categories, ::handleDeleteCategory, ::handleEditCategory) - - rvCategories.adapter = adapter - updateEmptyState() - } - } - - /** - * Fetches all categories for the current user from the backend. - */ - private suspend fun fetchCategories(): List { - 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() - } + rvCategories.adapter = adapter } /** @@ -133,30 +126,4 @@ class CategoriesActivity : AppCompatActivity() { } 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 - } } diff --git a/app/src/main/java/com/p_vacho/neat_calendar/adapters/CategoryAdapter.kt b/app/src/main/java/com/p_vacho/neat_calendar/adapters/CategoryAdapter.kt index 08b17a0..73a66e8 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/adapters/CategoryAdapter.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/adapters/CategoryAdapter.kt @@ -1,5 +1,6 @@ package com.p_vacho.neat_calendar.adapters +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -43,11 +44,10 @@ class CategoryAdapter( 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. + * Remove an existing category from the list and notify the adapter. */ - fun removeCategoryAt(position: Int) { + fun removeCategory(category: CategoryResponse) { + val position = categories.indexOfFirst { it.id == category.id } categories.removeAt(position) notifyItemRemoved(position) @@ -68,12 +68,24 @@ class CategoryAdapter( /** * 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) { val position = categories.indexOfFirst { it.id == category.id } categories[position] = category 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) { + categories.clear() + categories.addAll(newCategories) + + // We can't be more efficient here, we're changing the whole data set + @Suppress("NotifyDataSetChanged") + notifyDataSetChanged() + } } diff --git a/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CategoriesViewModel.kt b/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CategoriesViewModel.kt new file mode 100644 index 0000000..ea43abd --- /dev/null +++ b/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CategoriesViewModel.kt @@ -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) : 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() + + private val _isEmptyStateVisible = MutableLiveData() + val isEmptyStateVisible: LiveData = _isEmptyStateVisible + + private val _categoryEvents = MutableLiveData() + val categoryEvents: LiveData = _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()).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() + } +}