Compare commits

...

2 commits

6 changed files with 344 additions and 212 deletions

View file

@ -5,35 +5,27 @@ import android.os.Bundle
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
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.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.lifecycleScope
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.CalendarDayItemAdapter import com.p_vacho.neat_calendar.adapters.CalendarDayItemAdapter
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.EventResponse
import com.p_vacho.neat_calendar.models.CalendarDay import com.p_vacho.neat_calendar.models.CalendarDay
import kotlinx.coroutines.launch import com.p_vacho.neat_calendar.viewmodels.CalendarViewModel
import java.time.YearMonth import java.time.YearMonth
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
class CalendarActivity : AppCompatActivity() { class CalendarActivity : AppCompatActivity() {
private val viewModel: CalendarViewModel by viewModels()
private lateinit var adapter: CalendarDayItemAdapter
private lateinit var tvMonthYear: TextView private lateinit var tvMonthYear: TextView
private lateinit var rvCalendar: RecyclerView private lateinit var rvCalendar: RecyclerView
private lateinit var btnPreviousMonth: ImageButton private lateinit var btnPreviousMonth: ImageButton
private lateinit var btnNextMonth: ImageButton private lateinit var btnNextMonth: ImageButton
private val tokenManager by lazy { (application as MyApplication).tokenManager }
private var currentYearMonth: YearMonth = YearMonth.now()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@ -44,125 +36,31 @@ class CalendarActivity : AppCompatActivity() {
insets insets
} }
initializeViews()
// Observers
viewModel.monthYearText.observe(this) { text -> tvMonthYear.text = text }
viewModel.calendarDays.observe(this) {days -> adapter.updateDays(days) }
// Listeners
btnPreviousMonth.setOnClickListener { viewModel.prevMonth() }
btnNextMonth.setOnClickListener { viewModel.nextMonth() }
// Setup for current month
viewModel.setCalendarMonth(YearMonth.now())
}
private fun initializeViews() {
tvMonthYear = findViewById(R.id.tvMonthYear) tvMonthYear = findViewById(R.id.tvMonthYear)
rvCalendar = findViewById(R.id.rvCalendar) rvCalendar = findViewById(R.id.rvCalendar)
btnPreviousMonth = findViewById(R.id.btnPreviousMonth) btnPreviousMonth = findViewById(R.id.btnPreviousMonth)
btnNextMonth = findViewById(R.id.btnNextMonth) btnNextMonth = findViewById(R.id.btnNextMonth)
setCalendarForMonth(currentYearMonth) adapter = CalendarDayItemAdapter(listOf(), ::navigateToDayActivity)
btnPreviousMonth.setOnClickListener {
currentYearMonth = currentYearMonth.minusMonths(1)
setCalendarForMonth(currentYearMonth)
}
btnNextMonth.setOnClickListener {
currentYearMonth = currentYearMonth.plusMonths(1)
setCalendarForMonth(currentYearMonth)
}
}
/**
* Obtain all of the events that occur for each day in the given month from the API.
*
* The returned structure is a hashmap with int keys, being the day numbers in the month.
* If an event spans multiple days, it will be added for each of those days.
*
* Note that this also includes the events this user was invited into.
*/
private suspend fun fetchEventsForMonth(yearMonth: YearMonth): Map<Int, List<EventResponse>> {
// Use the system's default time zone
val zoneId = ZoneId.systemDefault()
// Get the start and end of the month in the local time zone
val startFrom = yearMonth.atDay(1).atStartOfDay(zoneId).toOffsetDateTime()
val endTo = yearMonth.atEndOfMonth().atTime(23, 59, 59).atZone(zoneId).toOffsetDateTime()
val userId = tokenManager.userId
if (userId.isNullOrEmpty()) {
navigateToMainActivity()
return emptyMap()
}
// BUG: Events that last longer than a month will not show
// up in the following month due to the startFrom filter here.
val userEvents = RetrofitClient.eventsService.userEvents(
userId = userId,
startFrom = startFrom,
startTo = endTo
)
val invitedEvents = RetrofitClient.eventsService.userEventsInvited(
userId = userId,
startFrom = startFrom,
startTo = endTo,
inviteStatus = "accepted"
)
val allEvents = userEvents + invitedEvents
// Create a mapping of days to events
val eventsByDay = mutableMapOf<Int, MutableList<EventResponse>>()
for (event in allEvents) {
val startDay = maxOf(event.start_time.dayOfMonth, 1)
val endDay = minOf(event.end_time.dayOfMonth, yearMonth.lengthOfMonth())
for (day in startDay..endDay) {
eventsByDay.computeIfAbsent(day) { mutableListOf() }.add(event)
}
}
return eventsByDay
}
private fun setCalendarForMonth(yearMonth: YearMonth) {
lifecycleScope.launch {
val firstDayOfMonth = yearMonth.atDay(1)
val daysInMonth = yearMonth.lengthOfMonth()
val eventsByDay = fetchEventsForMonth(yearMonth)
// Get localized month name and year
val monthYearText = "${
yearMonth.month.getDisplayName(
TextStyle.FULL,
Locale.getDefault()
)
} ${yearMonth.year}"
tvMonthYear.text = monthYearText
// Calculate the amount of placeholder slots until the first day in the month
// (Our calendar starts from Monday, but not all months start on monday, so we need fillers)
val startOffset = firstDayOfMonth.dayOfWeek.ordinal // Gives 0 (Monday) to 6 (Sunday)
// Generate calendar days
val days = mutableListOf<CalendarDay?>()
// Add padding (placeholder items) for days of the previous month
for (i in 1..startOffset) {
days.add(null)
}
// Add the actual days of the current month
for (i in 1..daysInMonth) {
val fullDate = yearMonth.atDay(i)
val eventsForDay = eventsByDay[i] ?: emptyList()
days.add(CalendarDay(date = fullDate, events = eventsForDay))
}
// Set up RecyclerView
rvCalendar.layoutManager = GridLayoutManager(this@CalendarActivity, 7) // 7 columns for days of the week rvCalendar.layoutManager = GridLayoutManager(this@CalendarActivity, 7) // 7 columns for days of the week
rvCalendar.adapter = CalendarDayItemAdapter(days) { day -> navigateToDayActivity(day) } rvCalendar.adapter = adapter
}
} }
private fun navigateToMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
finish() // Close the login screen
}
private fun navigateToDayActivity(day: CalendarDay) { private fun navigateToDayActivity(day: CalendarDay) {
var intent = Intent(this, DayViewActivity::class.java) var intent = Intent(this, DayViewActivity::class.java)
@ -177,6 +75,6 @@ class CalendarActivity : AppCompatActivity() {
// Events could've been modified or even deleted, so make sure we have a fresh copy // Events could've been modified or even deleted, so make sure we have a fresh copy
// it would probably be better to pass these around through extras, this extra lookup // it would probably be better to pass these around through extras, this extra lookup
// isn't a huge deal, it's a potential future optimization though // isn't a huge deal, it's a potential future optimization though
setCalendarForMonth(currentYearMonth) viewModel.refetch()
} }
} }

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

@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.p_vacho.neat_calendar.R import com.p_vacho.neat_calendar.R
import com.p_vacho.neat_calendar.models.CalendarDay import com.p_vacho.neat_calendar.models.CalendarDay
class CalendarDayItemAdapter(private val days: List<CalendarDay?>, private val onDayClicked: (CalendarDay) -> Unit) : class CalendarDayItemAdapter(private var days: List<CalendarDay?>, private val onDayClicked: (CalendarDay) -> Unit) :
RecyclerView.Adapter<CalendarDayItemAdapter.DayViewHolder>() { RecyclerView.Adapter<CalendarDayItemAdapter.DayViewHolder>() {
class DayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { class DayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
@ -49,4 +49,17 @@ class CalendarDayItemAdapter(private val days: List<CalendarDay?>, private val o
} }
override fun getItemCount(): Int = days.size override fun getItemCount(): Int = days.size
/**
* This function allows updating the day list.
*
* This will entirely overwrite the original data.
*/
fun updateDays(newDays: List<CalendarDay?>) {
days = newDays
// We're resetting to an entirely new list, this is needed
@Suppress("NotifyDataSetChanged")
notifyDataSetChanged()
}
} }

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,141 @@
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.EventResponse
import com.p_vacho.neat_calendar.models.CalendarDay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
class CalendarViewModel(application: Application) : AndroidViewModel(application) {
private var currentYearMonth: YearMonth? = null
private val _calendarDays = MutableLiveData<List<CalendarDay?>>()
val calendarDays: LiveData<List<CalendarDay?>> = _calendarDays
private val _monthYearText = MutableLiveData<String>()
val monthYearText: LiveData<String> = _monthYearText
/**
* Move to the next month.
*
* Can only be used after the initial setCalendarMonth.
*/
fun prevMonth() {
setCalendarMonth(currentYearMonth!!.minusMonths(1))
}
/**
* Move to the next month.
*
* Can only be used after the initial setCalendarMonth.
*/
fun nextMonth() {
setCalendarMonth(currentYearMonth!!.plusMonths(1))
}
/**
* Refetch the data for the currently selected month.
*
* Can only be used after the initial setCalendarMonth.
*/
fun refetch() {
setCalendarMonth(currentYearMonth!!)
}
/**
* Set up the view model for a specific month.
*/
fun setCalendarMonth(yearMonth: YearMonth) {
currentYearMonth = yearMonth
val firstDayOfMonth = yearMonth.atDay(1)
val daysInMonth = yearMonth.lengthOfMonth()
// Get localized month name and year name
val monthYearText = "${
yearMonth.month.getDisplayName(
TextStyle.FULL,
Locale.getDefault()
)
} ${yearMonth.year}"
_monthYearText.value = monthYearText
viewModelScope.launch {
val eventsByDay = withContext(Dispatchers.IO) { fetchEventsForMonthInternal(yearMonth) }
val startOffset = firstDayOfMonth.dayOfWeek.ordinal
val days = mutableListOf<CalendarDay?>()
for (i in 1..startOffset) {
days.add(null)
}
for (i in 1..daysInMonth) {
val fullDate = yearMonth.atDay(i)
val eventsForDay = eventsByDay[i] ?: emptyList()
days.add(CalendarDay(date = fullDate, events = eventsForDay))
}
_calendarDays.value = days
}
}
/**
* Obtain all of the events that occur for each day in the given month from the API.
*
* The returned structure is a hashmap with int keys, being the day numbers in the month.
* If an event spans multiple days, it will be added for each of those days.
*
* Note that this also includes the events this user was invited into.
*/
private suspend fun fetchEventsForMonthInternal(yearMonth: YearMonth): Map<Int, List<EventResponse>> {
// Use the system's default time zone
val zoneId = ZoneId.systemDefault()
// Get the start and end of the month in the local time zone
val startFrom = yearMonth.atDay(1).atStartOfDay(zoneId).toOffsetDateTime()
val endTo = yearMonth.atEndOfMonth().atTime(23, 59, 59).atZone(zoneId).toOffsetDateTime()
// Obtain current (logged in) user ID
val userId = (getApplication<MyApplication>()).tokenManager.userId ?: throw IllegalStateException("User ID is not set")
// BUG: Events that last longer than a month will not show
// up in the following month due to the startFrom filter here.
val userEvents = RetrofitClient.eventsService.userEvents(
userId = userId,
startFrom = startFrom,
startTo = endTo
)
val invitedEvents = RetrofitClient.eventsService.userEventsInvited(
userId = userId,
startFrom = startFrom,
startTo = endTo,
inviteStatus = "accepted"
)
val allEvents = userEvents + invitedEvents
// Create a mapping of days to events
val eventsByDay = mutableMapOf<Int, MutableList<EventResponse>>()
for (event in allEvents) {
val startDay = maxOf(event.start_time.dayOfMonth, 1)
val endDay = minOf(event.end_time.dayOfMonth, yearMonth.lengthOfMonth())
for (day in startDay..endDay) {
eventsByDay.computeIfAbsent(day) { mutableListOf() }.add(event)
}
}
return eventsByDay
}
}

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()
}
}