From fa2bb2c78abc36c343dfde2f253f243803d5a565 Mon Sep 17 00:00:00 2001 From: Peter Vacho Date: Wed, 22 Jan 2025 17:18:53 +0100 Subject: [PATCH] chore(mvvm): CreateCategoryActivity ViewModel --- app/build.gradle.kts | 1 + .../activities/CategoriesActivity.kt | 1 + .../activities/CreateCategoryActivity.kt | 162 ++++++------------ .../viewmodels/CreateCategoryViewModel.kt | 109 ++++++++++++ 4 files changed, 162 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CreateCategoryViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b678c3..efd6254 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") implementation("androidx.core:core-splashscreen:1.0.1") implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:converter-gson:2.11.0") 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 dd88b2d..2dba975 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 @@ -18,6 +18,7 @@ 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.CategoryMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext diff --git a/app/src/main/java/com/p_vacho/neat_calendar/activities/CreateCategoryActivity.kt b/app/src/main/java/com/p_vacho/neat_calendar/activities/CreateCategoryActivity.kt index 41dda6e..fd60fe7 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/activities/CreateCategoryActivity.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/activities/CreateCategoryActivity.kt @@ -2,42 +2,24 @@ package com.p_vacho.neat_calendar.activities import android.content.Intent import android.content.res.ColorStateList -import android.graphics.Color import android.os.Bundle -import android.util.Log import android.widget.Toast -import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat +import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import com.github.dhaval2404.colorpicker.ColorPickerDialog import com.google.android.material.button.MaterialButton import com.google.android.material.textfield.TextInputEditText -import com.google.gson.Gson import com.p_vacho.neat_calendar.R -import com.p_vacho.neat_calendar.api.RetrofitClient -import com.p_vacho.neat_calendar.api.models.CategoryRequest import com.p_vacho.neat_calendar.api.models.CategoryResponse -import com.p_vacho.neat_calendar.api.models.PartialCategoryRequest -import com.p_vacho.neat_calendar.api.models.ValidationError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import retrofit2.HttpException -import kotlin.properties.Delegates - -enum class CategoryMode { - CREATE, EDIT -} +import com.p_vacho.neat_calendar.viewmodels.CategoryMode +import com.p_vacho.neat_calendar.viewmodels.CreateCategoryViewModel class CreateCategoryActivity : AppCompatActivity() { - private var selectedColor by Delegates.notNull() - private var existingCategory: CategoryResponse? = null - private lateinit var mode: CategoryMode + private val viewModel: CreateCategoryViewModel by viewModels() + - // UI components private lateinit var etCategoryName: TextInputEditText private lateinit var btnColorPicker: MaterialButton private lateinit var btnSaveCategory: MaterialButton @@ -45,114 +27,72 @@ 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 - } - initializeViews() - mode = intent.getStringExtra("mode")!!.let { CategoryMode.valueOf(it) } + val mode = intent.getStringExtra("mode")?.let { CategoryMode.valueOf(it) } ?: CategoryMode.CREATE + @Suppress("DEPRECATION") + val category = intent.getParcelableExtra("category") - if (mode == CategoryMode.EDIT) { - @Suppress("DEPRECATION") - existingCategory = intent.getParcelableExtra("category")!! - - etCategoryName.setText(existingCategory?.name) - selectedColor = existingCategory?.color?.toArgb() ?: ContextCompat.getColor(this, R.color.category_indicator_color) - btnSaveCategory.setText(R.string.update_category) - } else { - selectedColor = ContextCompat.getColor(this, R.color.category_indicator_color) - } - - btnColorPicker.iconTint = ColorStateList.valueOf(selectedColor) + initializeViews(mode) + viewModel.initialize(mode, category) + setupObservers() setupListeners() } - private fun initializeViews() { + private fun initializeViews(mode: CategoryMode) { etCategoryName = findViewById(R.id.etCategoryName) btnColorPicker = findViewById(R.id.btnColorPicker) btnSaveCategory = findViewById(R.id.btnSaveCategory) btnCancel = findViewById(R.id.btnCancel) + + if (mode == CategoryMode.EDIT) { + btnSaveCategory.setText(R.string.update_category) + } + } + + private fun setupObservers() { + lifecycleScope.launchWhenStarted { + viewModel.state.collect { state -> + // Only update the text if it's different to avoid overwriting user input + if (etCategoryName.text?.toString() != state.categoryName) { + etCategoryName.setText(state.categoryName) + } + + btnColorPicker.iconTint = ColorStateList.valueOf(state.selectedColor) + + if (state.errorMessage != null) { + Toast.makeText(this@CreateCategoryActivity, state.errorMessage, Toast.LENGTH_SHORT).show() + } + + // Handle successful category save + state.successCategory?.let { category -> + val intent = Intent().apply { + if (state.isCreateMode) { + putExtra("newCategory", category) + } else { + putExtra("editedCategory", category) + } + } + setResult(RESULT_OK, intent) + finish() // Finish activity and return the result + } + } + } } private fun setupListeners() { btnColorPicker.setOnClickListener { openColorPickerDialog() } - btnSaveCategory.setOnClickListener { saveCategory() } + etCategoryName.addTextChangedListener { viewModel.onCategoryNameChanged(it.toString()) } + btnSaveCategory.setOnClickListener { viewModel.saveCategory() } btnCancel.setOnClickListener { finish() } } - private fun saveCategory() { - val categoryName = etCategoryName.text.toString().trim() - if (categoryName.isEmpty()) { - Toast.makeText(this, getString(R.string.please_enter_a_category_name), Toast.LENGTH_SHORT).show() - return - } - - lifecycleScope.launch(Dispatchers.IO) { - try { - val resultCategory = if (mode == CategoryMode.CREATE) { - val request = - CategoryRequest(name = categoryName, color = Color.valueOf(selectedColor)) - RetrofitClient.categoryService.createCategory(request) - } else { - val request = PartialCategoryRequest( - name = categoryName.takeIf { it != existingCategory?.name }, - color = Color.valueOf(selectedColor) - .takeIf { it != existingCategory?.color } - ) - RetrofitClient.categoryService.updateCategory(existingCategory!!.id, request) - } - - withContext(Dispatchers.Main) { handleCategorySaved(resultCategory) } - } catch (e: HttpException) { - if (e.code() != 422) { - throw e - } - - val errorBody = e.response()?.errorBody()?.string() - val validationError = Gson().fromJson(errorBody, ValidationError::class.java) - val errMsg = validationError.detail.joinToString("\n") - - Log.e("CategoryCreate", "Got HTTP 422: $validationError") - - withContext(Dispatchers.Main) { - Toast.makeText( - this@CreateCategoryActivity, - getString(R.string.failed_to_save_category, errMsg), - Toast.LENGTH_SHORT - ).show() - } - } - } - } - - private fun handleCategorySaved(category: CategoryResponse) { - Log.w("CreateCategory", "Ending") - val intent = Intent().apply { - if (mode == CategoryMode.CREATE) { - putExtra("newCategory", category) - Log.w("CreateCategory", "New") - } else { - putExtra("editedCategory", category) - Log.w("CreateCategory", "Edit") - } - } - setResult(RESULT_OK, intent) - finish() - } - private fun openColorPickerDialog() { ColorPickerDialog .Builder(this) .setTitle(getString(R.string.select_color)) - .setDefaultColor(selectedColor) - .setColorListener { color, _ -> - selectedColor = color - btnColorPicker.iconTint = ColorStateList.valueOf(color) - } + .setDefaultColor(viewModel.state.value.selectedColor) + .setColorListener { color, _ -> viewModel.onColorSelected(color) } .show() } } diff --git a/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CreateCategoryViewModel.kt b/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CreateCategoryViewModel.kt new file mode 100644 index 0000000..9b199c3 --- /dev/null +++ b/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CreateCategoryViewModel.kt @@ -0,0 +1,109 @@ +package com.p_vacho.neat_calendar.viewmodels + +import android.app.Application +import android.graphics.Color +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import com.p_vacho.neat_calendar.R +import com.p_vacho.neat_calendar.api.RetrofitClient +import com.p_vacho.neat_calendar.api.models.CategoryRequest +import com.p_vacho.neat_calendar.api.models.CategoryResponse +import com.p_vacho.neat_calendar.api.models.PartialCategoryRequest +import com.p_vacho.neat_calendar.api.models.ValidationError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import retrofit2.HttpException + +enum class CategoryMode { + CREATE, EDIT +} + +data class CreateCategoryState( + val categoryName: String = "", + val selectedColor: Int = Color.GRAY, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val successCategory: CategoryResponse? = null, + val isCreateMode: Boolean = true, +) + +class CreateCategoryViewModel(application: Application) : AndroidViewModel(application) { + private val _state = MutableStateFlow(CreateCategoryState()) + val state = _state.asStateFlow() + + private var existingCategory: CategoryResponse? = null + + fun initialize(mode: CategoryMode, category: CategoryResponse?) { + _state.value = _state.value.copy( + categoryName = category?.name.orEmpty(), + selectedColor = category?.color?.toArgb() + ?: getApplication().getColor(R.color.category_indicator_color), + isCreateMode = mode == CategoryMode.CREATE + ) + existingCategory = category + } + + fun onCategoryNameChanged(name: String) { + if (name != _state.value.categoryName) { + _state.value = _state.value.copy(categoryName = name) + } + } + + fun onColorSelected(color: Int) { + if (color != _state.value.selectedColor) { + _state.value = _state.value.copy(selectedColor = color) + } + } + + fun saveCategory() { + val categoryName = _state.value.categoryName.trim() + if (categoryName.isEmpty()) { + _state.value = _state.value.copy( + errorMessage = getApplication().getString( + R.string.please_enter_a_category_name + ) + ) + return + } + + _state.value = _state.value.copy(isLoading = true, errorMessage = null) + + viewModelScope.launch { + try { + val resultCategory = if (_state.value.isCreateMode) { + val request = CategoryRequest( + name = categoryName, + color = Color.valueOf(_state.value.selectedColor) + ) + RetrofitClient.categoryService.createCategory(request) + } else { + existingCategory?.let { category -> + val request = PartialCategoryRequest( + name = categoryName.takeIf { it != category.name }, + color = Color.valueOf(_state.value.selectedColor).takeIf { it != category.color } + ) + RetrofitClient.categoryService.updateCategory(category.id, request) + } ?: throw IllegalStateException("Category is required for EDIT mode") + } + + _state.value = _state.value.copy(successCategory = resultCategory) + } catch (e: HttpException) { + if (e.code() != 422) throw e + val errorBody = e.response()?.errorBody()?.string() + val validationError = Gson().fromJson(errorBody, ValidationError::class.java) + val errMsg = validationError.detail.joinToString("\n") + + Log.e("CategoryCreate", "Got HTTP 422: $validationError") + + _state.value = _state.value.copy( + errorMessage = getApplication().getString(R.string.failed_to_save_category, errMsg) + ) + } finally { + _state.value = _state.value.copy(isLoading = false) + } + } + } +}