chore(mvvm): CreateCategoryActivity ViewModel
This commit is contained in:
parent
98dc983435
commit
fa2bb2c78a
|
@ -48,6 +48,7 @@ dependencies {
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||||
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
|
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
|
||||||
|
|
|
@ -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.adapters.CategoryAdapter
|
||||||
import com.p_vacho.neat_calendar.api.RetrofitClient
|
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.CategoryMode
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
|
@ -2,42 +2,24 @@ package com.p_vacho.neat_calendar.activities
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.github.dhaval2404.colorpicker.ColorPickerDialog
|
import com.github.dhaval2404.colorpicker.ColorPickerDialog
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
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.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.CategoryResponse
|
||||||
import com.p_vacho.neat_calendar.api.models.PartialCategoryRequest
|
import com.p_vacho.neat_calendar.viewmodels.CategoryMode
|
||||||
import com.p_vacho.neat_calendar.api.models.ValidationError
|
import com.p_vacho.neat_calendar.viewmodels.CreateCategoryViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import retrofit2.HttpException
|
|
||||||
import kotlin.properties.Delegates
|
|
||||||
|
|
||||||
enum class CategoryMode {
|
|
||||||
CREATE, EDIT
|
|
||||||
}
|
|
||||||
|
|
||||||
class CreateCategoryActivity : AppCompatActivity() {
|
class CreateCategoryActivity : AppCompatActivity() {
|
||||||
private var selectedColor by Delegates.notNull<Int>()
|
private val viewModel: CreateCategoryViewModel by viewModels()
|
||||||
private var existingCategory: CategoryResponse? = null
|
|
||||||
private lateinit var mode: CategoryMode
|
|
||||||
|
|
||||||
// UI components
|
|
||||||
private lateinit var etCategoryName: TextInputEditText
|
private lateinit var etCategoryName: TextInputEditText
|
||||||
private lateinit var btnColorPicker: MaterialButton
|
private lateinit var btnColorPicker: MaterialButton
|
||||||
private lateinit var btnSaveCategory: MaterialButton
|
private lateinit var btnSaveCategory: MaterialButton
|
||||||
|
@ -45,114 +27,72 @@ class CreateCategoryActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
|
||||||
setContentView(R.layout.activity_create_category)
|
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()
|
val mode = intent.getStringExtra("mode")?.let { CategoryMode.valueOf(it) } ?: CategoryMode.CREATE
|
||||||
mode = intent.getStringExtra("mode")!!.let { CategoryMode.valueOf(it) }
|
|
||||||
|
|
||||||
if (mode == CategoryMode.EDIT) {
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
existingCategory = intent.getParcelableExtra("category")!!
|
val category = intent.getParcelableExtra<CategoryResponse>("category")
|
||||||
|
|
||||||
etCategoryName.setText(existingCategory?.name)
|
initializeViews(mode)
|
||||||
selectedColor = existingCategory?.color?.toArgb() ?: ContextCompat.getColor(this, R.color.category_indicator_color)
|
viewModel.initialize(mode, category)
|
||||||
btnSaveCategory.setText(R.string.update_category)
|
setupObservers()
|
||||||
} else {
|
|
||||||
selectedColor = ContextCompat.getColor(this, R.color.category_indicator_color)
|
|
||||||
}
|
|
||||||
|
|
||||||
btnColorPicker.iconTint = ColorStateList.valueOf(selectedColor)
|
|
||||||
setupListeners()
|
setupListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeViews() {
|
private fun initializeViews(mode: CategoryMode) {
|
||||||
etCategoryName = findViewById(R.id.etCategoryName)
|
etCategoryName = findViewById(R.id.etCategoryName)
|
||||||
btnColorPicker = findViewById(R.id.btnColorPicker)
|
btnColorPicker = findViewById(R.id.btnColorPicker)
|
||||||
btnSaveCategory = findViewById(R.id.btnSaveCategory)
|
btnSaveCategory = findViewById(R.id.btnSaveCategory)
|
||||||
btnCancel = findViewById(R.id.btnCancel)
|
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() {
|
private fun setupListeners() {
|
||||||
btnColorPicker.setOnClickListener { openColorPickerDialog() }
|
btnColorPicker.setOnClickListener { openColorPickerDialog() }
|
||||||
btnSaveCategory.setOnClickListener { saveCategory() }
|
etCategoryName.addTextChangedListener { viewModel.onCategoryNameChanged(it.toString()) }
|
||||||
|
btnSaveCategory.setOnClickListener { viewModel.saveCategory() }
|
||||||
btnCancel.setOnClickListener { finish() }
|
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() {
|
private fun openColorPickerDialog() {
|
||||||
ColorPickerDialog
|
ColorPickerDialog
|
||||||
.Builder(this)
|
.Builder(this)
|
||||||
.setTitle(getString(R.string.select_color))
|
.setTitle(getString(R.string.select_color))
|
||||||
.setDefaultColor(selectedColor)
|
.setDefaultColor(viewModel.state.value.selectedColor)
|
||||||
.setColorListener { color, _ ->
|
.setColorListener { color, _ -> viewModel.onColorSelected(color) }
|
||||||
selectedColor = color
|
|
||||||
btnColorPicker.iconTint = ColorStateList.valueOf(color)
|
|
||||||
}
|
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue