chore(mvvm): CreateCategoryActivity ViewModel

This commit is contained in:
Peter Vacho 2025-01-22 17:18:53 +01:00
parent 98dc983435
commit fa2bb2c78a
Signed by: school
GPG key ID: 8CFC3837052871B4
4 changed files with 162 additions and 111 deletions

View file

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

View file

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

View file

@ -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<Int>()
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<CategoryResponse>("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()
}
}

View file

@ -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<Application>().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<Application>().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<Application>().getString(R.string.failed_to_save_category, errMsg)
)
} finally {
_state.value = _state.value.copy(isLoading = false)
}
}
}
}