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.TextView
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.recyclerview.widget.GridLayoutManager
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.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 kotlinx.coroutines.launch
import com.p_vacho.neat_calendar.viewmodels.CalendarViewModel
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
class CalendarActivity : AppCompatActivity() {
private val viewModel: CalendarViewModel by viewModels()
private lateinit var adapter: CalendarDayItemAdapter
private lateinit var tvMonthYear: TextView
private lateinit var rvCalendar: RecyclerView
private lateinit var btnPreviousMonth: 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?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@ -44,125 +36,31 @@ class CalendarActivity : AppCompatActivity() {
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)
rvCalendar = findViewById(R.id.rvCalendar)
btnPreviousMonth = findViewById(R.id.btnPreviousMonth)
btnNextMonth = findViewById(R.id.btnNextMonth)
setCalendarForMonth(currentYearMonth)
btnPreviousMonth.setOnClickListener {
currentYearMonth = currentYearMonth.minusMonths(1)
setCalendarForMonth(currentYearMonth)
}
btnNextMonth.setOnClickListener {
currentYearMonth = currentYearMonth.plusMonths(1)
setCalendarForMonth(currentYearMonth)
}
adapter = CalendarDayItemAdapter(listOf(), ::navigateToDayActivity)
rvCalendar.layoutManager = GridLayoutManager(this@CalendarActivity, 7) // 7 columns for days of the week
rvCalendar.adapter = adapter
}
/**
* 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.adapter = CalendarDayItemAdapter(days) { day -> navigateToDayActivity(day) }
}
}
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) {
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
// 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
setCalendarForMonth(currentYearMonth)
viewModel.refetch()
}
}

View file

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

View file

@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.p_vacho.neat_calendar.R
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>() {
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
/**
* 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
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<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()
}
}