chore(mvvm): CalendarActivity ViewModel

This commit is contained in:
Peter Vacho 2025-01-22 21:59:54 +01:00
parent 6832d25163
commit 851b3c1f38
Signed by: school
GPG key ID: 8CFC3837052871B4
3 changed files with 179 additions and 127 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)
rvCalendar.layoutManager = GridLayoutManager(this@CalendarActivity, 7) // 7 columns for days of the week
btnPreviousMonth.setOnClickListener { rvCalendar.adapter = adapter
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.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) { 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

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

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